diff options
Diffstat (limited to 'src/client/shell')
35 files changed, 24174 insertions, 0 deletions
diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs new file mode 100644 index 00000000..efc9aae9 --- /dev/null +++ b/src/client/shell/actions.rs @@ -0,0 +1,1051 @@ +use super::*; + +impl ClientShellState { + pub(super) fn record_binding( + &mut self, + binding: crate::input::KeybindMatch, + outcome: &mut ClientShellInput, + ) { + match binding { + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Detach) => { + outcome.detach = true; + } + crate::input::KeybindMatch::Action(crate::input::KeybindAction::ToggleSidebar) => { + self.sidebar_collapsed = !self.sidebar_collapsed; + self.sidebar_collapsed_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + } + crate::input::KeybindMatch::Action(action) => { + if matches!( + action, + crate::input::KeybindAction::NewWorktree + | crate::input::KeybindAction::OpenWorktree + | crate::input::KeybindAction::RemoveWorktree + ) { + self.begin_worktree_action(action, outcome); + return; + } + if action == crate::input::KeybindAction::OpenNavigator { + self.open_navigator_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::Help { + self.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: false, + scroll: 0, + })); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::Settings { + self.open_settings_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::OpenNotificationTarget { + self.focus_visible_notification(outcome); + return; + } + if action == crate::input::KeybindAction::ReloadConfig { + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ServerReloadConfig( + crate::api::schema::EmptyParams::default(), + ), + PendingEndpointKind::ReloadConfig, + outcome, + ); + self.reload_client_config(); + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + return; + } + if action == crate::input::KeybindAction::NewWorkspace { + if self.config.prompt_new_workspace_name { + self.open_new_workspace_overlay(); + } else { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + source_workspace_id: self.workspace_action_id(), + cwd: None, + focus: true, + label: None, + env: Default::default(), + }, + ), + outcome, + ); + } + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::RenameWorkspace { + self.open_rename_workspace_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::CloseWorkspace { + if let Some(workspace_id) = self.workspace_action_id() { + if self.config.confirm_close { + self.open_confirm_close_overlay(workspace_id); + } else { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id, + close_group: true, + }, + ), + outcome, + ); + } + } + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::NewTab && self.config.prompt_new_tab_name + { + self.open_new_tab_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::RenameTab { + self.open_rename_tab_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::RenamePane { + self.open_rename_pane_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::WorkspacePicker { + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; + self.mode = ClientShellMode::Navigate; + self.navigate_workspace_id = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::EnterResizeMode { + self.mode = ClientShellMode::Resize; + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::CopyMode { + if self.enter_copy_mode(outcome) { + outcome.repaint = true; + } + return; + } + if let Some(method) = self.endpoint_method_for_action(action) { + self.push_endpoint_method(method, outcome); + return; + } + outcome.actions.push(ClientShellAction::Keybind(action)); + } + crate::input::KeybindMatch::Command(command) => { + let action = command.action.into(); + let resolved_labels = command.bindings.labels(); + let command_id = self.snapshot.as_deref().and_then(|snapshot| { + if let Some(candidate) = snapshot.commands.iter().find(|candidate| { + candidate.command_id == command.command && candidate.action == action + }) { + return Some(candidate.command_id.clone()); + } + let mut candidates = snapshot.commands.iter().filter(|candidate| { + candidate.action == action + && !resolved_labels.is_empty() + && resolved_labels + .iter() + .all(|label| candidate.binding_labels.contains(label)) + }); + let candidate = candidates.next()?; + candidates + .next() + .is_none() + .then(|| candidate.command_id.clone()) + }); + let Some(command_id) = command_id else { + self.endpoint_error = Some( + "custom command is not available on this endpoint; reload configuration" + .to_owned(), + ); + outcome.repaint = true; + return; + }; + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let selection = (action == crate::protocol::ClientShellCommandAction::PluginAction) + .then(|| { + let selection = self.selection.as_ref()?; + if !selection.is_visible() { + return None; + } + if snapshot.focused_pane_id.as_deref() != Some(selection.pane_id.as_str()) { + return None; + } + let content_revision = self + .pane_surface + .as_ref()? + .panes + .iter() + .find(|pane| pane.pane_id == selection.pane_id)? + .content_revision; + let (anchor, cursor) = selection.ordered_cells(); + Some(crate::api::schema::PaneSelectionReadParams { + pane_id: selection.pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { + row: anchor.0, + col: anchor.1, + }, + cursor: crate::api::schema::PaneTextPoint { + row: cursor.0, + col: cursor.1, + }, + content_revision: Some(content_revision), + }) + }) + .flatten(); + let params = crate::api::schema::CommandInvokeParams { + command_id, + workspace_id: snapshot.focused_workspace_id.clone(), + tab_id: snapshot.focused_tab_id.clone(), + pane_id: snapshot.focused_pane_id.clone(), + selection, + }; + if action == crate::protocol::ClientShellCommandAction::Popup { + self.popup_pending = true; + self.popup_pending_deadline = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::CommandInvoke(params), + PendingEndpointKind::PopupCommand, + outcome, + ); + } else { + self.push_endpoint_method( + crate::api::schema::Method::CommandInvoke(params), + outcome, + ); + } + } + } + } + + pub(super) fn request_selection_copy(&mut self, outcome: &mut ClientShellInput) { + self.request_selection_copy_with_fallback(outcome, None); + } + + pub(super) fn request_selection_copy_with_fallback( + &mut self, + outcome: &mut ClientShellInput, + fallback_key: Option<crate::input::TerminalKey>, + ) { + let Some(selection) = self.selection.as_ref() else { + return; + }; + let pane_id = selection.pane_id.clone(); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| surface.panes.iter().find(|pane| pane.pane_id == pane_id)) + .map(|pane| pane.content_revision); + let (anchor, cursor) = selection.ordered_cells(); + let fallback = fallback_key.and_then(|key| { + let press = ClientPaneInputEvent::from_terminal_key(key.clone())?; + let tracks_release = matches!( + &press, + ClientPaneInputEvent::Key { + tracks_release: true, + .. + } + ); + let mut message = + super::target_event_message(ClientInputTarget::Pane(pane_id.clone()), press); + if tracks_release { + let release = ClientPaneInputEvent::from_terminal_key( + key.with_kind(crossterm::event::KeyEventKind::Release), + )?; + if let ClientMessage::ClientShellPaneInput { events, .. } = &mut message { + events.push(release); + } + } + Some(message) + }); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneSelectionRead( + crate::api::schema::PaneSelectionReadParams { + pane_id, + anchor: crate::api::schema::PaneTextPoint { + row: anchor.0, + col: anchor.1, + }, + cursor: crate::api::schema::PaneTextPoint { + row: cursor.0, + col: cursor.1, + }, + content_revision, + }, + ), + PendingEndpointKind::SelectionCopy { fallback }, + outcome, + ); + } + + pub(super) fn request_word_selection( + &mut self, + hit: &PaneHit, + viewport_row: u16, + col: u16, + outcome: &mut ClientShellInput, + ) { + let absolute_row = crate::selection::absolute_row_for_viewport(viewport_row, hit.scroll); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| { + surface + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) + .map(|pane| pane.content_revision); + self.word_selection_generation = self.word_selection_generation.saturating_add(1); + let generation = self.word_selection_generation; + self.pending_word_selection = Some(generation); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneSelectionRead( + crate::api::schema::PaneSelectionReadParams { + pane_id: hit.pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { + row: absolute_row, + col: 0, + }, + cursor: crate::api::schema::PaneTextPoint { + row: absolute_row, + col: hit.inner_rect.width.saturating_sub(1), + }, + content_revision, + }, + ), + PendingEndpointKind::WordSelection { + pane_id: hit.pane_id.clone(), + absolute_row, + col, + generation, + }, + outcome, + ); + } + + pub(super) fn push_endpoint_method( + &mut self, + method: crate::api::schema::Method, + outcome: &mut ClientShellInput, + ) { + self.push_endpoint_method_with_kind(method, PendingEndpointKind::Generic, outcome); + } + + pub(super) fn push_endpoint_method_with_kind( + &mut self, + method: crate::api::schema::Method, + kind: PendingEndpointKind, + outcome: &mut ClientShellInput, + ) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let confirmation_workspace_id = match &method { + crate::api::schema::Method::TabClose(target) => snapshot + .tabs + .iter() + .find(|tab| tab.tab_id == target.tab_id) + .map(|tab| tab.workspace_id.clone()), + crate::api::schema::Method::PaneClose(target) => snapshot + .panes + .iter() + .find(|pane| pane.pane_id == target.pane_id) + .map(|pane| pane.workspace_id.clone()), + _ => None, + }; + let request_id = self.next_request_id; + self.next_request_id = self.next_request_id.saturating_add(1); + let request_id = format!("client-shell:{request_id}"); + self.pending_requests.insert( + request_id.clone(), + PendingEndpointRequest { + boot_id: snapshot.boot_id.clone(), + confirmation_workspace_id, + kind, + }, + ); + outcome.actions.push(ClientShellAction::Endpoint { + boot_id: snapshot.boot_id.clone(), + request: Box::new(crate::api::schema::Request { + id: request_id, + method, + }), + }); + } + + pub(crate) fn receive_endpoint_error(&mut self, message: String) -> bool { + if self.endpoint_error.as_deref() == Some(message.as_str()) { + return false; + } + self.endpoint_error = Some(message); + true + } + + pub(crate) fn handle_endpoint_result( + &mut self, + boot_id: &str, + request_id: &str, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + ) -> (bool, Vec<ClientShellAction>) { + let Some(pending) = self.pending_requests.remove(request_id) else { + return (false, Vec::new()); + }; + if pending.boot_id != boot_id + || self + .snapshot + .as_deref() + .is_none_or(|snapshot| snapshot.boot_id != boot_id) + { + return (false, Vec::new()); + } + match pending.kind { + PendingEndpointKind::Generic => {} + PendingEndpointKind::ProductAnnouncementDismiss { version, id } => { + return match result { + Ok(_) => (false, Vec::new()), + Err(error) => { + let key = (version.clone(), id.clone()); + if self.dismissed_product_announcement.as_ref() == Some(&key) { + self.dismissed_product_announcement = None; + } + if self.overlay.is_none() { + if let Some(announcement) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.product_announcement.as_ref()) + .filter(|announcement| { + announcement.version == version && announcement.id == id + }) + { + self.overlay = Some(ClientShellOverlay::ProductAnnouncement( + product_announcement_state(announcement), + )); + } + } + self.endpoint_error = Some(error.message); + (true, Vec::new()) + } + }; + } + PendingEndpointKind::ReleaseNotesDismiss => { + return match result { + Ok(_) => (false, Vec::new()), + Err(error) => { + if self.overlay.is_none() { + if let Some(notes) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.release_notes.as_ref()) + { + self.overlay = Some(ClientShellOverlay::ReleaseNotes( + release_notes_state(notes), + )); + } + } + self.endpoint_error = Some(error.message); + (true, Vec::new()) + } + }; + } + PendingEndpointKind::PopupCommand => { + return match result { + Ok(_) => { + self.popup_pending_deadline = + Some(std::time::Instant::now() + std::time::Duration::from_secs(1)); + (false, Vec::new()) + } + Err(error) => { + self.popup_pending = false; + self.popup_pending_deadline = None; + self.endpoint_error = Some(error.message); + (true, Vec::new()) + } + }; + } + PendingEndpointKind::PaneScroll { pane_id, serial } => { + let mut outcome = ClientShellInput::default(); + let repaint = self.complete_pane_scroll(pane_id, serial, result, &mut outcome); + return (repaint, outcome.actions); + } + PendingEndpointKind::SelectionCopy { fallback } => { + let fallback = || { + fallback + .map(ClientShellAction::Request) + .into_iter() + .collect::<Vec<_>>() + }; + return match result { + Ok(crate::api::schema::ResponseResult::PaneSelection { text, .. }) + if !text.is_empty() => + { + let repaint = self.show_copy_feedback(std::time::Instant::now()); + ( + repaint, + vec![ClientShellAction::ClipboardWrite(text.into_bytes())], + ) + } + Ok(crate::api::schema::ResponseResult::PaneSelection { .. }) => { + (false, fallback()) + } + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected selection result".to_owned()); + (true, fallback()) + } + Err(error) => { + self.endpoint_error = Some(error.message); + (true, fallback()) + } + }; + } + PendingEndpointKind::WordSelection { + pane_id, + absolute_row, + col, + generation, + } => { + if self.pending_word_selection != Some(generation) + || self.snapshot.as_deref().is_none_or(|snapshot| { + !snapshot.panes.iter().any(|pane| pane.pane_id == pane_id) + }) + { + return (false, Vec::new()); + } + self.pending_word_selection = None; + let row_text = match result { + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: returned_pane_id, + text, + }) if returned_pane_id == pane_id => text, + Ok(crate::api::schema::ResponseResult::PaneSelection { .. }) => { + return (false, Vec::new()) + } + Ok(_) => { + self.endpoint_error = Some( + "endpoint returned an unexpected word-selection result".to_owned(), + ); + return (true, Vec::new()); + } + Err(error) => { + self.endpoint_error = Some(error.message); + return (true, Vec::new()); + } + }; + let Some((start_col, end_col)) = + crate::app::actions::word_bounds_at_column(&row_text, col) + else { + self.selection = None; + return (true, Vec::new()); + }; + let mut selection = crate::selection::Selection::absolute_range( + pane_id, + (absolute_row, start_col), + (absolute_row, end_col), + ); + if !selection.finish() { + return (false, Vec::new()); + } + self.selection = Some(selection); + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + if !self.config.copy_on_select { + return (true, Vec::new()); + } + self.selection_highlight_clear_deadline = + Some(std::time::Instant::now() + std::time::Duration::from_millis(500)); + let mut outcome = ClientShellInput::default(); + self.request_selection_copy(&mut outcome); + return (true, outcome.actions); + } + PendingEndpointKind::PaneLinkActivate { + pane_id, + inner_rect, + fallback_events, + } => { + let completed_before_release = !fallback_events.iter().any(|event| { + event.kind + == crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) + }); + let replay = (self.mode == ClientShellMode::Terminal + && self.overlay.is_none() + && self + .hits + .panes + .iter() + .any(|hit| hit.pane_id == pane_id && hit.inner_rect == inner_rect)) + .then_some(fallback_events); + if replay.is_none() { + self.url_click_consumes_until_up = completed_before_release; + } + let replay_action = |events: Option<Vec<crossterm::event::MouseEvent>>| { + events + .map(ClientShellAction::ReplayMouse) + .into_iter() + .collect() + }; + return match result { + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + handled: true, + .. + }) => { + self.url_click_consumes_until_up = completed_before_release; + (false, Vec::new()) + } + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: Some(url), + handled: false, + }) if crate::app::actions::safe_web_url(&url).is_some() => { + self.url_click_consumes_until_up = completed_before_release; + (false, vec![ClientShellAction::OpenSafeWebUrl(url)]) + } + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { .. }) => { + (false, replay_action(replay)) + } + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected link result".to_owned()); + (true, replay_action(replay)) + } + Err(error) + if matches!( + error.code.as_deref(), + Some("stale_content" | "stale_target") + ) => + { + self.url_click_consumes_until_up = completed_before_release; + (false, Vec::new()) + } + Err(error) => { + self.endpoint_error = Some(error.message); + (true, replay_action(replay)) + } + }; + } + PendingEndpointKind::CopyMotion { + pane_id, + origin, + session_generation, + } => { + let mut outcome = ClientShellInput::default(); + let (repaint, continue_queue) = match result { + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: returned_pane_id, + cursor, + content_revision, + }) if returned_pane_id == pane_id => ( + self.apply_copy_motion_target( + &pane_id, + origin, + cursor, + content_revision, + &mut outcome, + ), + true, + ), + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { .. }) => (false, false), + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected copy-motion result".to_owned()); + (true, false) + } + Err(error) => { + self.endpoint_error = Some(error.message); + (true, false) + } + }; + self.complete_copy_operation(session_generation, continue_queue, &mut outcome); + return (repaint || outcome.repaint, outcome.actions); + } + PendingEndpointKind::CopySearch { + pane_id, + origin, + query, + direction, + repeat, + generation, + session_generation, + } => { + let mut outcome = ClientShellInput::default(); + let (repaint, continue_queue) = match result { + Ok(crate::api::schema::ResponseResult::PaneCopySearch { + pane_id: returned_pane_id, + content_revision, + matches, + total, + current, + current_global, + }) if returned_pane_id == pane_id => { + let repaint = self.apply_copy_search_result( + &pane_id, + origin, + query, + direction, + repeat, + generation, + ClientCopySearchResult { + content_revision, + matches, + total, + current: current.and_then(|index| usize::try_from(index).ok()), + current_global, + }, + &mut outcome, + ); + if !repaint { + self.cancel_deferred_copy_after_search(generation); + } + (repaint, repaint) + } + Ok(crate::api::schema::ResponseResult::PaneCopySearch { .. }) => { + self.cancel_deferred_copy_after_search(generation); + (false, false) + } + Ok(_) => { + self.cancel_deferred_copy_after_search(generation); + self.endpoint_error = + Some("endpoint returned an unexpected copy-search result".to_owned()); + (true, false) + } + Err(error) => { + self.cancel_deferred_copy_after_search(generation); + self.endpoint_error = Some(error.message); + (true, false) + } + }; + self.complete_copy_operation(session_generation, continue_queue, &mut outcome); + return (repaint || outcome.repaint, outcome.actions); + } + PendingEndpointKind::ReloadConfig => { + let repaint = match result { + Ok(crate::api::schema::ResponseResult::ConfigReload { .. }) => false, + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected config reload result".to_owned()); + true + } + Err(error) => { + self.endpoint_error = Some(error.message); + true + } + }; + return (repaint, Vec::new()); + } + kind @ (PendingEndpointKind::IntegrationList + | PendingEndpointKind::IntegrationInstall) => { + return self.handle_settings_endpoint_result(kind, result); + } + kind => { + return ( + self.handle_worktree_endpoint_result(kind, result), + Vec::new(), + ); + } + } + let repaint = match result { + Ok(_) => false, + Err(error) + if self.config.confirm_close + && error.code.as_deref() == Some("confirmation_required") + && pending.confirmation_workspace_id.is_some() => + { + if let Some(workspace_id) = pending.confirmation_workspace_id { + self.open_confirm_close_overlay(workspace_id); + } + true + } + Err(error) => { + self.endpoint_error = Some(error.message); + true + } + }; + (repaint, Vec::new()) + } + + pub(super) fn endpoint_method_for_action( + &mut self, + action: crate::input::KeybindAction, + ) -> Option<crate::api::schema::Method> { + use crate::api::schema::{ + Method, PaneDirection, PaneFocusDirectionParams, PaneResizeParams, PaneSplitParams, + PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, SplitDirection, + TabCreateParams, TabMoveParams, TabTarget, WorkspaceTarget, + }; + use crate::input::KeybindAction; + + let snapshot = self.snapshot.as_deref()?; + let focused_workspace = snapshot.focused_workspace_id.clone()?; + let focused_tab = snapshot.focused_tab_id.clone(); + let focused_pane = snapshot.focused_pane_id.clone(); + let direction = |action| match action { + KeybindAction::FocusPaneLeft + | KeybindAction::SwapPaneLeft + | KeybindAction::ResizePaneLeft => Some(PaneDirection::Left), + KeybindAction::FocusPaneDown + | KeybindAction::SwapPaneDown + | KeybindAction::ResizePaneDown => Some(PaneDirection::Down), + KeybindAction::FocusPaneUp + | KeybindAction::SwapPaneUp + | KeybindAction::ResizePaneUp => Some(PaneDirection::Up), + KeybindAction::FocusPaneRight + | KeybindAction::SwapPaneRight + | KeybindAction::ResizePaneRight => Some(PaneDirection::Right), + _ => None, + }; + + match action { + KeybindAction::FocusAgent(index) => { + let agents = super::agent_sidebar::ordered_agent_pane_ids( + snapshot, + self.config.agent_panel_sort, + ); + Some(Method::PaneFocus(PaneTarget { + pane_id: agents.get(index)?.clone(), + })) + } + KeybindAction::PreviousAgent | KeybindAction::NextAgent => { + let agents = super::agent_sidebar::ordered_agent_pane_ids( + snapshot, + self.config.agent_panel_sort, + ); + if agents.is_empty() { + return None; + } + let current = agents.iter().position(|pane_id| { + Some(pane_id.as_str()) == snapshot.focused_pane_id.as_deref() + }); + let next = match (current, action) { + (Some(current), KeybindAction::PreviousAgent) => { + (current + agents.len() - 1) % agents.len() + } + (Some(current), KeybindAction::NextAgent) => (current + 1) % agents.len(), + (None, KeybindAction::PreviousAgent) => agents.len() - 1, + (None, KeybindAction::NextAgent) => 0, + _ => unreachable!("relative agent action"), + }; + let pane_id = agents[next].clone(); + if !self + .hits + .agents + .iter() + .any(|(_, visible_pane_id)| visible_pane_id == &pane_id) + { + self.agent_scroll = next.min(self.hits.agent_max_scroll); + } + Some(Method::PaneFocus(PaneTarget { pane_id })) + } + KeybindAction::SwitchWorkspace(index) => { + let entries = self.navigation_workspace_entries(snapshot); + let workspace_id = snapshot + .workspaces + .get(entries.get(index)?.index)? + .workspace_id + .clone(); + self.reveal_workspace(&workspace_id); + Some(Method::WorkspaceFocus(WorkspaceTarget { workspace_id })) + } + KeybindAction::PreviousWorkspace | KeybindAction::NextWorkspace => { + let entries = self.navigation_workspace_entries(snapshot); + if entries.is_empty() { + return None; + } + let current = entries + .iter() + .position(|entry| { + snapshot.workspaces[entry.index].workspace_id == focused_workspace + }) + .unwrap_or(0); + let delta = if action == KeybindAction::PreviousWorkspace { + -1 + } else { + 1 + }; + let next = (current as isize + delta).rem_euclid(entries.len() as isize) as usize; + let workspace_id = snapshot.workspaces[entries[next].index] + .workspace_id + .clone(); + self.reveal_workspace(&workspace_id); + Some(Method::WorkspaceFocus(WorkspaceTarget { workspace_id })) + } + KeybindAction::SwitchTab(index) => { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == focused_workspace) + .collect::<Vec<_>>(); + Some(Method::TabFocus(TabTarget { + tab_id: tabs.get(index)?.tab_id.clone(), + })) + } + KeybindAction::PreviousTab | KeybindAction::NextTab => { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == focused_workspace) + .collect::<Vec<_>>(); + let focused_tab = focused_tab?; + let current = tabs.iter().position(|tab| tab.tab_id == focused_tab)?; + let delta = if action == KeybindAction::PreviousTab { + -1 + } else { + 1 + }; + let next = (current as isize + delta).rem_euclid(tabs.len() as isize) as usize; + Some(Method::TabFocus(TabTarget { + tab_id: tabs[next].tab_id.clone(), + })) + } + KeybindAction::MoveTabPrevious | KeybindAction::MoveTabNext => { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == focused_workspace) + .collect::<Vec<_>>(); + if tabs.len() <= 1 { + return None; + } + let focused_tab = focused_tab?; + let source = tabs.iter().position(|tab| tab.tab_id == focused_tab)?; + let insert_index = if action == KeybindAction::MoveTabNext { + if source + 1 >= tabs.len() { + 0 + } else { + source + 2 + } + } else if source == 0 { + tabs.len() + } else { + source - 1 + }; + Some(Method::TabMove(TabMoveParams { + tab_id: focused_tab, + insert_index, + })) + } + KeybindAction::NewTab if !self.config.prompt_new_tab_name => { + Some(Method::TabCreate(TabCreateParams { + workspace_id: Some(focused_workspace), + cwd: None, + focus: true, + label: None, + env: Default::default(), + })) + } + KeybindAction::FocusPaneLeft + | KeybindAction::FocusPaneDown + | KeybindAction::FocusPaneUp + | KeybindAction::FocusPaneRight => { + Some(Method::PaneFocusDirection(PaneFocusDirectionParams { + pane_id: focused_pane, + direction: direction(action)?, + })) + } + KeybindAction::SwapPaneLeft + | KeybindAction::SwapPaneDown + | KeybindAction::SwapPaneUp + | KeybindAction::SwapPaneRight => Some(Method::PaneSwap(PaneSwapParams { + pane_id: focused_pane, + direction: Some(direction(action)?), + source_pane_id: None, + target_pane_id: None, + })), + KeybindAction::SplitVertical | KeybindAction::SplitHorizontal => { + Some(Method::PaneSplit(PaneSplitParams { + workspace_id: Some(focused_workspace), + target_pane_id: focused_pane, + direction: if action == KeybindAction::SplitVertical { + SplitDirection::Right + } else { + SplitDirection::Down + }, + ratio: None, + cwd: None, + focus: true, + right_click: Default::default(), + env: Default::default(), + })) + } + KeybindAction::CloseTab => Some(Method::TabClose(TabTarget { + tab_id: focused_tab?, + })), + KeybindAction::ClosePane => Some(Method::PaneClose(PaneTarget { + pane_id: focused_pane.clone()?, + })), + KeybindAction::CyclePaneNext | KeybindAction::CyclePanePrevious => { + let focused_tab = focused_tab?; + let panes = snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == focused_tab) + .collect::<Vec<_>>(); + if panes.is_empty() { + return None; + } + let focused_pane = focused_pane?; + let current = panes + .iter() + .position(|pane| pane.pane_id == focused_pane) + .unwrap_or(0); + let next = if action == KeybindAction::CyclePanePrevious { + (current + panes.len() - 1) % panes.len() + } else { + (current + 1) % panes.len() + }; + Some(Method::PaneFocus(PaneTarget { + pane_id: panes[next].pane_id.clone(), + })) + } + KeybindAction::LastPane => { + let pane_id = self.previous_pane_id.as_ref()?; + if Some(pane_id.as_str()) == focused_pane.as_deref() + || !snapshot.panes.iter().any(|pane| &pane.pane_id == pane_id) + { + return None; + } + Some(Method::PaneFocus(PaneTarget { + pane_id: pane_id.clone(), + })) + } + KeybindAction::Zoom => Some(Method::PaneZoom(PaneZoomParams { + pane_id: focused_pane, + mode: PaneZoomMode::Toggle, + })), + KeybindAction::EditScrollback => Some(Method::PaneEditScrollback(PaneTarget { + pane_id: focused_pane?, + })), + KeybindAction::ResizePaneLeft + | KeybindAction::ResizePaneDown + | KeybindAction::ResizePaneUp + | KeybindAction::ResizePaneRight => Some(Method::PaneResize(PaneResizeParams { + pane_id: focused_pane, + direction: direction(action)?, + amount: None, + })), + _ => None, + } + } +} diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs new file mode 100644 index 00000000..8f87b6ad --- /dev/null +++ b/src/client/shell/agent_sidebar.rs @@ -0,0 +1,343 @@ +use std::collections::HashMap; + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, + text::Line, + widgets::{Paragraph, Widget}, +}; + +use super::*; + +struct AgentRow { + pane_id: String, + status: crate::api::schema::AgentStatus, + focused: bool, + rows: Vec<Vec<crate::ui::ResolvedToken>>, +} + +pub(super) fn ordered_agent_pane_ids( + snapshot: &ClientShellSnapshot, + sort: crate::config::AgentPanelSortConfig, +) -> Vec<String> { + if snapshot.agent_view_label.is_some() { + return snapshot + .agent_order + .iter() + .filter(|pane_id| { + snapshot + .agents + .iter() + .any(|agent| agent.pane_id == pane_id.as_str()) + }) + .cloned() + .collect(); + } + let mut agents = snapshot.agents.iter().collect::<Vec<_>>(); + if sort == crate::config::AgentPanelSortConfig::Priority { + agents.sort_by_key(|agent| { + ( + std::cmp::Reverse(status_priority(agent.agent_status)), + std::cmp::Reverse(agent.state_change_seq), + ) + }); + } + agents + .into_iter() + .map(|agent| agent.pane_id.clone()) + .collect() +} + +pub(super) fn render_agent_panel( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + agent_scroll: &mut usize, + hits: &mut ShellHitMap, +) { + if area.height == 0 { + return; + } + put_text( + buffer, + area.x, + area.y, + area.width, + &"─".repeat(area.width as usize), + Style::default().fg(config.palette.surface_dim), + ); + if area.height < 2 { + return; + } + put_text( + buffer, + area.x, + area.y + 1, + area.width, + " agents", + Style::default() + .fg(config.palette.overlay0) + .add_modifier(Modifier::BOLD), + ); + let sort_label = + snapshot + .agent_view_label + .as_deref() + .unwrap_or(match config.agent_panel_sort { + crate::config::AgentPanelSortConfig::Spaces => "grouped", + crate::config::AgentPanelSortConfig::Priority => "priority", + }); + let sort_width = display_width(sort_label).min(area.width as usize) as u16; + let sort_rect = Rect::new( + area.right().saturating_sub(sort_width), + area.y + 1, + sort_width, + 1, + ); + hits.agent_sort_toggle = if config.mouse_capture && snapshot.agent_view_label.is_none() { + sort_rect + } else { + Rect::default() + }; + put_text( + buffer, + sort_rect.x, + sort_rect.y, + sort_rect.width, + sort_label, + Style::default() + .fg(if snapshot.agent_view_label.is_some() { + config.palette.accent + } else { + config.palette.overlay0 + }) + .add_modifier(Modifier::BOLD), + ); + + let rows = agent_rows(snapshot, config); + let body = Rect::new( + area.x, + area.y.saturating_add(3), + area.width, + area.height.saturating_sub(3), + ); + hits.agent_body = body; + if body.is_empty() || rows.is_empty() { + *agent_scroll = 0; + if !body.is_empty() && snapshot.agent_view_label.is_some() { + put_text( + buffer, + body.x, + body.y, + body.width, + " no matching agents", + Style::default() + .fg(config.palette.overlay0) + .add_modifier(Modifier::DIM), + ); + } + return; + } + + let row_heights = rows + .iter() + .map(|row| row.rows.len().max(1).min(u16::MAX as usize) as u16) + .collect::<Vec<_>>(); + let gaps = rows + .iter() + .enumerate() + .map(|(index, _)| { + if index + 1 < rows.len() { + config.agents.row_gap + } else { + 0 + } + }) + .collect::<Vec<_>>(); + let metrics = + super::scroll::list_scroll_metrics(&row_heights, &gaps, body.height, *agent_scroll); + hits.agent_max_scroll = metrics.max_offset_from_bottom; + hits.agent_scroll_metrics = Some(metrics); + *agent_scroll = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom); + let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1; + let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); + let mut y = body.y; + for (index, row) in rows.iter().enumerate().skip(*agent_scroll) { + let height = (row.rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + if y.saturating_add(height) > body.bottom() { + break; + } + let rect = Rect::new(body.x, y, content_width, height); + hits.agents.push((rect, row.pane_id.clone())); + render_agent_row(buffer, rect, row, config); + y = y + .saturating_add(height) + .saturating_add(if index + 1 < rows.len() { + config.agents.row_gap + } else { + 0 + }); + } + + if show_scrollbar { + let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height); + hits.agent_scrollbar = track; + super::scroll::render_list_scrollbar(buffer, track, metrics, &config.palette); + } +} + +fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec<AgentRow> { + ordered_agent_pane_ids(snapshot, config.agent_panel_sort) + .into_iter() + .filter_map(|pane_id| { + let agent = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id)?; + let workspace = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == agent.workspace_id)?; + let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); + let pane = snapshot + .panes + .iter() + .find(|pane| pane.pane_id == agent.pane_id); + let tab_count = snapshot + .tabs + .iter() + .filter(|candidate| candidate.workspace_id == agent.workspace_id) + .count(); + let tab_label = tab + .filter(|tab| tab_count > 1 || tab.custom_label) + .map(|tab| tab.label.as_str()); + let agent_label = agent + .display_agent + .as_deref() + .or(agent.name.as_deref()) + .or(agent.agent.as_deref()) + .or(agent.title.as_deref()); + let labels = agent + .state_labels + .iter() + .cloned() + .collect::<HashMap<_, _>>(); + let tokens = agent.tokens.iter().cloned().collect::<HashMap<_, _>>(); + let state_text = labels + .get(status_text(agent.agent_status)) + .map(String::as_str) + .unwrap_or_else(|| sidebar_status_text(agent.agent_status)); + let canonical_agent = agent + .agent + .as_deref() + .and_then(crate::detect::parse_agent_label); + let rows = crate::ui::sidebar_agent_rows( + &config.agents, + crate::ui::AgentTokenContext { + workspace: &workspace.label, + tab: tab_label, + pane: agent + .title + .as_deref() + .or_else(|| pane.and_then(|pane| pane.label.as_deref())), + agent_label, + terminal_title: agent.terminal_title.as_deref(), + terminal_title_stripped: agent.terminal_title_stripped.as_deref(), + canonical_agent, + tokens: &tokens, + }, + state_text, + ); + Some(AgentRow { + pane_id: agent.pane_id.clone(), + status: agent.agent_status, + focused: agent.focused, + rows, + }) + }) + .collect() +} + +fn render_agent_row(buffer: &mut Buffer, rect: Rect, row: &AgentRow, config: &ClientShellConfig) { + let palette = &config.palette; + let row_style = if row.focused { + Style::default().bg(palette.active_row_bg) + } else { + Style::default() + }; + let name_style = if row.focused { + Style::default() + .fg(palette.text) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(palette.subtext0) + .add_modifier(Modifier::BOLD) + }; + let status_style = Style::default() + .fg(status_color(row.status, palette)) + .add_modifier(if row.focused { + Modifier::empty() + } else { + Modifier::DIM + }); + let secondary = Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::DIM); + let icon = ( + status_icon(row.status, config.status_indicators), + Style::default().fg(status_color(row.status, palette)), + ); + let rows = if row.rows.is_empty() { + vec![vec![crate::ui::ResolvedToken { + kind: crate::ui::ResolvedTokenKind::StateIcon, + style: Default::default(), + }]] + } else { + row.rows.clone() + }; + for (index, tokens) in rows.iter().take(rect.height as usize).enumerate() { + let indent = if index == 0 { 1 } else { 3 }; + let mut spans = vec![ratatui::text::Span::raw(" ".repeat(indent))]; + spans.extend(crate::ui::resolved_token_spans( + tokens, + icon, + status_style, + name_style, + secondary, + secondary, + palette, + rect.width.saturating_sub(indent as u16) as usize, + )); + Paragraph::new(Line::from(spans)).style(row_style).render( + Rect::new(rect.x, rect.y + index as u16, rect.width, 1), + buffer, + ); + } +} + +fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) { + for (offset, character) in text.chars().take(width as usize).enumerate() { + if let Some(cell) = buffer.cell_mut((x + offset as u16, y)) { + cell.set_char(character).set_style(style); + } + } +} + +fn display_width(text: &str) -> usize { + unicode_width::UnicodeWidthStr::width(text) +} + +fn sidebar_status_text(status: crate::api::schema::AgentStatus) -> &'static str { + use crate::api::schema::AgentStatus; + match status { + AgentStatus::Blocked => "blocked", + AgentStatus::Done => "done", + AgentStatus::Working => "working", + AgentStatus::Idle | AgentStatus::Unknown => "idle", + } +} diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs new file mode 100644 index 00000000..6432f3e1 --- /dev/null +++ b/src/client/shell/composition.rs @@ -0,0 +1,539 @@ +use super::*; + +fn restore_mode_bar( + frame: &mut FrameData, + bar: Option<Rect>, + cells: Option<&[crate::protocol::CellData]>, +) { + let (Some(bar), Some(cells)) = (bar, cells) else { + return; + }; + let start = usize::from(bar.y) * usize::from(frame.width) + usize::from(bar.x); + frame.cells[start..start + usize::from(bar.width)].clone_from_slice(cells); + if frame + .cursor + .as_ref() + .is_some_and(|cursor| cursor.y == bar.y) + { + frame.cursor = None; + } +} + +impl ClientShellState { + pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> { + self.last_composed_size = Some((cols, rows)); + let snapshot = self.snapshot.as_deref()?; + let surface = self.pane_surface.as_ref()?; + if snapshot.revision != surface.projection_revision { + return None; + } + let layout = self.layout(cols, rows); + if self.last_tab_bar_width != Some(layout.tab_bar.width) { + self.last_tab_bar_width = Some(layout.tab_bar.width); + self.reveal_focused_tab = true; + } + let tab_drag_insert_index = match &self.chrome_drag { + Some(ClientChromeDrag::Tab { insert_index, .. }) => *insert_index, + _ => None, + }; + let (dragged_workspace_id, workspace_drop_indicator_row) = match &self.chrome_drag { + Some(ClientChromeDrag::Workspace { + source_workspace_id, + target, + }) => ( + Some(source_workspace_id.as_str()), + target.as_ref().map(|(_, row)| *row), + ), + _ => (None, None), + }; + let mut buffer = Buffer::empty(Rect::new(0, 0, cols, rows)); + self.hits = render::render_shell( + &mut buffer, + layout, + snapshot, + &self.config, + render::ShellRenderState { + collapsed_groups: &self.collapsed_groups, + workspace_scroll: &mut self.workspace_scroll, + agent_scroll: &mut self.agent_scroll, + tab_scroll: &mut self.tab_scroll, + reveal_focused_tab: &mut self.reveal_focused_tab, + sidebar_collapsed: self.sidebar_collapsed, + sidebar_section_split: self.sidebar_section_split, + tab_drag_insert_index, + selected_workspace_id: (self.mode == ClientShellMode::Navigate) + .then_some(self.navigate_workspace_id.as_deref()) + .flatten(), + dragged_workspace_id, + workspace_drop_indicator_row, + }, + ); + self.hits.panes = surface + .panes + .iter() + .map(|pane| PaneHit { + rect: Rect::new( + layout.pane_surface.x.saturating_add(pane.rect.x), + layout.pane_surface.y.saturating_add(pane.rect.y), + pane.rect.width, + pane.rect.height, + ), + inner_rect: Rect::new( + layout.pane_surface.x.saturating_add(pane.inner_rect.x), + layout.pane_surface.y.saturating_add(pane.inner_rect.y), + pane.inner_rect.width, + pane.inner_rect.height, + ), + scrollbar_rect: pane.scrollbar_rect.map(|rect| { + Rect::new( + layout.pane_surface.x.saturating_add(rect.x), + layout.pane_surface.y.saturating_add(rect.y), + rect.width, + rect.height, + ) + }), + scroll: pane.scroll.map(|metrics| crate::pane::ScrollMetrics { + offset_from_bottom: usize::try_from(metrics.offset_from_bottom) + .unwrap_or(usize::MAX), + max_offset_from_bottom: usize::try_from(metrics.max_offset_from_bottom) + .unwrap_or(usize::MAX), + viewport_rows: usize::try_from(metrics.viewport_rows).unwrap_or(usize::MAX), + }), + pane_id: pane.pane_id.clone(), + popup: false, + mouse_reporting: pane.mouse_reporting, + sgr_pixel_mouse: pane.sgr_pixel_mouse, + pixel_width: pane.pixel_width, + pixel_height: pane.pixel_height, + }) + .collect(); + let topology_signature = pane_surface_topology_signature(surface); + self.hits.pane_splits = surface + .splits + .iter() + .map(|split| PaneSplitHit { + direction: split.direction, + pos: match split.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => { + layout.pane_surface.x.saturating_add(split.pos) + } + crate::protocol::PaneSurfaceSplitDirection::Vertical => { + layout.pane_surface.y.saturating_add(split.pos) + } + }, + area: Rect::new( + layout.pane_surface.x.saturating_add(split.area.x), + layout.pane_surface.y.saturating_add(split.area.y), + split.area.width, + split.area.height, + ), + hit_rect: Rect::new( + layout.pane_surface.x.saturating_add(split.hit_rect.x), + layout.pane_surface.y.saturating_add(split.hit_rect.y), + split.hit_rect.width, + split.hit_rect.height, + ), + path: split.path.clone(), + topology_signature, + }) + .collect(); + if !self.config.mouse_capture { + self.hits.pane_splits.clear(); + } + let mode_bar_area = if layout.mobile_header.is_empty() + && self.config.tab_bar_position == TabBarPositionConfig::Bottom + && !layout.tab_bar.is_empty() + { + layout.tab_bar + } else { + layout.pane_surface + }; + let mobile_navigate_panel = !layout.mobile_header.is_empty() + && self.mode == ClientShellMode::Navigate + && self.endpoint_error.is_none(); + let mode_bar = if mobile_navigate_panel || self.overlay.is_some() { + None + } else { + render::render_mode_bar( + &mut buffer, + mode_bar_area, + self.mode, + self.copy_mode.as_ref(), + self.endpoint_error.as_deref(), + snapshot.update_available.is_some(), + &self.config.keybinds, + &self.config.palette, + ) + }; + if mode_bar == Some(layout.tab_bar) { + self.hits.tabs.clear(); + self.hits.new_tab = Rect::default(); + self.hits.tab_scroll_left = Rect::default(); + self.hits.tab_scroll_right = Rect::default(); + } + let mut frame = FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[]); + let mode_bar_cells = mode_bar.map(|bar| { + let start = usize::from(bar.y) * usize::from(frame.width) + usize::from(bar.x); + frame.cells[start..start + usize::from(bar.width)].to_vec() + }); + blit_pane_surface(&mut frame, &surface.frame, layout.pane_surface); + restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + let has_selection = self + .selection + .as_ref() + .is_some_and(|selection| selection.is_visible()); + let has_search = self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| !copy_mode.search_matches.is_empty()); + if has_selection || has_search { + let cursor = frame.cursor.clone(); + let mut composed = frame.to_ratatui_buffer()?; + for hit in &self.hits.panes { + let copy_surface_coherent = + client_copy_surface_coherent(self.copy_mode.as_ref(), hit); + if copy_surface_coherent { + render_client_copy_search_highlights( + &mut composed, + self.copy_mode.as_ref(), + hit, + &self.config.palette, + false, + ); + } + let selection_is_stale_copy_projection = !copy_surface_coherent + && self.copy_mode.as_ref().is_some_and(|copy_mode| { + copy_mode.pane_id == hit.pane_id + && self + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == hit.pane_id) + }); + if !selection_is_stale_copy_projection { + crate::ui::render_selection_highlight( + self.selection.as_ref(), + &mut composed, + &hit.pane_id, + hit.inner_rect, + hit.scroll, + &self.config.palette, + crate::terminal_theme::TerminalTheme::default(), + ); + } + if copy_surface_coherent { + render_client_copy_search_highlights( + &mut composed, + self.copy_mode.as_ref(), + hit, + &self.config.palette, + true, + ); + } + } + frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); + } + if self.mode == ClientShellMode::Copy { + frame.cursor = None; + if let Some(copy_mode) = self.copy_mode.as_ref() { + if let Some(hit) = self.hits.panes.iter().find(|hit| { + hit.pane_id == copy_mode.pane_id + && client_copy_surface_coherent(Some(copy_mode), hit) + }) { + let viewport_top = copy_mode + .max_offset_from_bottom + .saturating_sub(copy_mode.offset_from_bottom) + .min(u32::MAX as usize) as u32; + let viewport_row = copy_mode.cursor.row.saturating_sub(viewport_top); + if viewport_row < u32::from(hit.inner_rect.height) + && copy_mode.cursor.col < hit.inner_rect.width + { + let mut composed = frame.to_ratatui_buffer()?; + let x = hit.inner_rect.x + copy_mode.cursor.col; + let y = hit.inner_rect.y + viewport_row as u16; + composed[(x, y)].set_style( + Style::default() + .fg(match self.config.palette.panel_bg { + ratatui::style::Color::Reset => self.config.palette.surface_dim, + color => color, + }) + .bg(self.config.palette.accent) + .add_modifier(Modifier::BOLD), + ); + frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); + } else { + frame.cursor = None; + } + } + } + } + restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + self.hits.notification_toast = Rect::default(); + let has_config_diagnostic = self.config_diagnostic.is_some(); + if has_config_diagnostic || self.visible_notification.is_some() { + let cursor = frame.cursor.clone(); + let mut composed = frame.to_ratatui_buffer()?; + if let Some(diagnostic) = self.config_diagnostic.as_deref() { + let diagnostic_area = if layout.mobile_header.is_empty() { + Rect::new(0, 0, cols, rows) + } else { + layout.pane_surface + }; + crate::ui::render_config_diagnostic_buffer( + &mut composed, + diagnostic_area, + diagnostic, + &self.config.palette, + ); + } + if let Some(notification) = self.visible_notification.as_ref() { + self.hits.notification_toast = if layout.mobile_header.is_empty() { + notifications::render_visible_notification( + &mut composed, + Rect::new(0, 0, cols, rows), + notification, + self.config.toast_position, + u16::from(has_config_diagnostic), + &self.config.palette, + ) + } else { + notifications::render_mobile_notification_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + notification, + has_config_diagnostic, + &self.config.palette, + ) + }; + } + frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); + } + if let Some(feedback) = self.copy_feedback.as_ref() { + let cursor = frame.cursor.clone(); + let mut composed = frame.to_ratatui_buffer()?; + let base_offset = u16::from(has_config_diagnostic); + let feedback_area = if layout.mobile_header.is_empty() { + layout.pane_surface + } else { + Rect::new(0, 0, cols, rows) + }; + let offset = crate::ui::copy_feedback_offset_for_toast( + feedback_area, + feedback, + base_offset, + self.config.clipboard_toast_position, + self.hits.notification_toast, + ); + crate::ui::render_copy_feedback_buffer( + &mut composed, + feedback_area, + feedback, + offset, + self.config.clipboard_toast_position, + &self.config.palette, + ); + frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); + } + self.hits.popup = None; + if let Some(popup) = surface.popup.as_deref() { + let width = popup.width.map(client_popup_size); + let height = popup.height.map(client_popup_size); + if let Some(geometry) = + crate::popup_size::resolve_popup_geometry(width, height, layout.pane_surface) + { + let mut composed = frame.to_ratatui_buffer()?; + let block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::ALL) + .border_style(ratatui::style::Style::default().fg(self.config.palette.accent)) + .title(popup.title.clone()) + .style(ratatui::style::Style::default().bg(self.config.palette.panel_bg)); + ratatui::widgets::Widget::render( + ratatui::widgets::Clear, + geometry.outer, + &mut composed, + ); + ratatui::widgets::Widget::render(block, geometry.outer, &mut composed); + frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); + blit_pane_surface(&mut frame, &popup.frame, geometry.inner); + self.hits.popup = Some(PaneHit { + rect: geometry.outer, + inner_rect: geometry.inner, + scrollbar_rect: None, + scroll: None, + pane_id: popup.terminal_id.clone(), + popup: true, + mouse_reporting: popup.mouse_reporting, + sgr_pixel_mouse: popup.sgr_pixel_mouse, + pixel_width: popup.pixel_width, + pixel_height: popup.pixel_height, + }); + } + } + if !layout.mobile_header.is_empty() + && self.mode == ClientShellMode::Navigate + && self.overlay.is_none() + { + let mut composed = frame.to_ratatui_buffer()?; + super::mobile::render_mobile_switcher( + &mut composed, + Rect::new(0, 0, cols, rows), + snapshot, + &self.config, + self.navigate_workspace_id.as_deref(), + &mut self.mobile_switcher_scroll, + &mut self.reveal_mobile_workspace, + &mut self.hits, + ); + frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); + self.hits.panes.clear(); + self.hits.pane_splits.clear(); + self.hits.popup = None; + } + restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + if let Some(overlay) = self.overlay.as_ref() { + let mut composed = frame.to_ratatui_buffer()?; + let cursor = if let ClientShellOverlay::ContextMenu(menu) = overlay { + self.hits.context_menu_rows = + render::render_context_menu(&mut composed, menu, &self.config.palette)?; + None + } else if let ClientShellOverlay::GlobalMenu(menu) = overlay { + self.hits.global_menu_rows = render::render_global_menu( + &mut composed, + self.hits.global_launcher, + menu, + snapshot, + &self.config.palette, + )?; + None + } else { + let rendered = render::render_client_overlay( + &mut composed, + overlay, + snapshot, + &self.config.keybinds, + &self.config.palette, + )?; + self.hits.overlay_primary = rendered.primary; + self.hits.overlay_clear = rendered.clear; + self.hits.overlay_cancel = rendered.cancel; + self.hits.navigator_popup = rendered.navigator_popup; + self.hits.navigator_search = rendered.navigator_search; + self.hits.navigator_rows = rendered.navigator_rows; + self.hits.worktree_search = rendered.worktree_search; + self.hits.worktree_rows = rendered.worktree_rows; + self.hits.help_popup = rendered.help_popup; + self.hits.help_scrollbar = rendered.help_scrollbar; + self.hits.help_scroll_metrics = rendered.help_scroll_metrics; + self.hits.help_max_scroll = rendered.help_max_scroll; + self.hits.settings_popup = rendered.settings_popup; + self.hits.settings_tabs = rendered.settings_tabs; + self.hits.settings_choices = rendered.settings_choices; + self.hits.product_announcement_scrollbar = rendered.product_announcement_scrollbar; + self.hits.product_announcement_scroll_metrics = + rendered.product_announcement_scroll_metrics; + self.hits.product_announcement_max_scroll = + rendered.product_announcement_max_scroll; + self.hits.release_notes_scrollbar = rendered.release_notes_scrollbar; + self.hits.release_notes_scroll_metrics = rendered.release_notes_scroll_metrics; + self.hits.release_notes_max_scroll = rendered.release_notes_max_scroll; + rendered.cursor + }; + frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); + } + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = help.scroll.min(self.hits.help_max_scroll); + } + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = self.overlay.as_mut() { + announcement.scroll = announcement + .scroll + .min(u16::try_from(self.hits.product_announcement_max_scroll).unwrap_or(u16::MAX)); + } + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = notes + .scroll + .min(u16::try_from(self.hits.release_notes_max_scroll).unwrap_or(u16::MAX)); + } + self.compose_graphics(&mut frame, layout); + Some(frame) + } +} + +fn client_copy_surface_coherent(copy_mode: Option<&ClientCopyModeState>, hit: &PaneHit) -> bool { + copy_mode + .filter(|copy_mode| copy_mode.pane_id == hit.pane_id) + .is_none_or(|copy_mode| { + copy_mode.geometry == (hit.inner_rect.width, hit.inner_rect.height) + && hit.scroll.is_some_and(|scroll| { + scroll.offset_from_bottom == copy_mode.offset_from_bottom + && scroll.max_offset_from_bottom == copy_mode.max_offset_from_bottom + }) + }) +} + +fn render_client_copy_search_highlights( + buffer: &mut Buffer, + copy_mode: Option<&ClientCopyModeState>, + hit: &PaneHit, + palette: &Palette, + current_only: bool, +) { + let Some(copy_mode) = copy_mode.filter(|copy_mode| copy_mode.pane_id == hit.pane_id) else { + return; + }; + if hit.inner_rect.is_empty() { + return; + } + let top = copy_mode + .max_offset_from_bottom + .saturating_sub(copy_mode.offset_from_bottom) + .min(u32::MAX as usize) as u32; + let bottom = top.saturating_add(u32::from(hit.inner_rect.height.saturating_sub(1))); + let style = if current_only { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.surface1) + }; + for (index, text_match) in copy_mode.search_matches.iter().enumerate() { + if (copy_mode.search_current == Some(index)) != current_only + || text_match.end.row < top + || text_match.start.row > bottom + { + continue; + } + let start_row = text_match.start.row.max(top); + let end_row = text_match.end.row.min(bottom); + for absolute_row in start_row..=end_row { + let viewport_row = absolute_row.saturating_sub(top) as u16; + let start_col = if absolute_row == text_match.start.row { + text_match.start.col + } else { + 0 + }; + let end_col = if absolute_row == text_match.end.row { + text_match.end.col + } else { + hit.inner_rect.width.saturating_sub(1) + }; + for col in start_col..=end_col.min(hit.inner_rect.width.saturating_sub(1)) { + buffer[( + hit.inner_rect.x.saturating_add(col), + hit.inner_rect.y.saturating_add(viewport_row), + )] + .set_style(style); + } + } + } +} + +fn client_popup_size(size: crate::protocol::ClientShellPopupSize) -> crate::popup_size::PopupSize { + match size { + crate::protocol::ClientShellPopupSize::Cells(cells) => { + crate::popup_size::PopupSize::Cells(cells) + } + crate::protocol::ClientShellPopupSize::Percent(percent) => { + crate::popup_size::PopupSize::Percent(percent) + } + } +} diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs new file mode 100644 index 00000000..0273cefb --- /dev/null +++ b/src/client/shell/config.rs @@ -0,0 +1,503 @@ +use super::*; + +pub(super) fn merged_config_diagnostic( + local: Option<&str>, + endpoint: Option<&str>, +) -> Option<String> { + match (local, endpoint) { + (Some(local), Some(endpoint)) if local == endpoint => { + Some(format!("client + endpoint: {local}")) + } + (Some(local), Some(endpoint)) => Some(format!("client: {local}\nendpoint: {endpoint}")), + (Some(local), None) => Some(local.to_owned()), + (None, Some(endpoint)) => Some(endpoint.to_owned()), + (None, None) => None, + } +} + +impl ClientShellState { + pub(super) fn set_local_config_diagnostic(&mut self, diagnostic: Option<String>) { + self.local_config_diagnostic = diagnostic; + self.config_diagnostic = merged_config_diagnostic( + self.local_config_diagnostic.as_deref(), + self.snapshot + .as_deref() + .and_then(|snapshot| snapshot.config_diagnostic.as_deref()), + ); + } + + pub(super) fn persist_chrome_preferences(&mut self, outcome: &mut ClientShellInput) { + let Some(path) = self.config.preferences_path.as_deref() else { + return; + }; + let mut collapsed_groups = self.collapsed_groups.iter().cloned().collect::<Vec<_>>(); + collapsed_groups.sort(); + let preferences = preferences::ClientChromePreferences { + sidebar_width: self.sidebar_width_manual.then_some(self.sidebar_width), + sidebar_section_split: self + .sidebar_section_split_manual + .then_some(self.sidebar_section_split), + sidebar_collapsed: self + .sidebar_collapsed_manual + .then_some(self.sidebar_collapsed), + agent_panel_sort: self + .agent_panel_sort_manual + .then_some(self.config.agent_panel_sort), + collapsed_groups, + }; + if let Err(error) = preferences::store(path, preferences) { + self.endpoint_error = Some(error); + outcome.repaint = true; + } + } + + pub(crate) fn reload_client_config(&mut self) { + match crate::config::load_live_config() { + Ok(loaded) => { + let agent_panel_sort = self.config.agent_panel_sort; + let diagnostics = self.config.apply_live_config( + &loaded.config, + &loaded.diagnostics, + &loaded.invalid_sections, + ); + if let Some(appearance) = self.host_appearance { + self.config.palette = crate::app::client_palette_for_appearance( + &self.config.theme_runtime, + appearance, + ); + } + if !self.sidebar_width_manual { + self.sidebar_width = self.config.sidebar_width; + } + if self.agent_panel_sort_manual { + self.config.agent_panel_sort = agent_panel_sort; + } + self.set_local_config_diagnostic(self.config.local_config_diagnostic(&diagnostics)); + if let Some(snapshot) = self.snapshot.as_deref() { + let profile = snapshot.server_keybindings_toml.clone(); + let commands = snapshot.commands.clone(); + if let Err(err) = self + .config + .apply_snapshot_keybindings(profile.as_deref(), &commands) + { + self.endpoint_error = Some(err); + } + } + } + Err(diagnostics) => { + self.set_local_config_diagnostic(self.config.local_config_diagnostic(&diagnostics)); + } + } + self.reconcile_input_source(); + } +} + +impl ClientShellConfig { + pub(crate) fn from_config(config: &Config) -> Self { + let theme_runtime = crate::app::client_theme_runtime_from_config(config); + Self { + sidebar_width: config.ui.sidebar_width, + sidebar_min_width: config.ui.sidebar_min_width, + sidebar_max_width: config.ui.sidebar_max_width, + sidebar_start_collapsed: config.ui.sidebar_start_collapsed, + sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, + mobile_width_threshold: config.ui.mobile_width_threshold, + tab_bar_position: config.ui.tab_bar_position, + hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab, + spaces: config.ui.sidebar.spaces.clone(), + agents: config.ui.sidebar.agents.clone(), + agent_panel_sort: config.ui.agent_panel_sort, + status_indicators: config.ui.status_indicators, + sound_enabled: config.ui.sound.enabled, + toast_delivery: config.ui.toast.delivery, + toast_delay_seconds: config.ui.toast.delay_seconds, + toast_position: config.ui.toast.herdr.position, + copy_on_select: config.ui.copy_on_select, + clipboard_toast_enabled: config.ui.toast.clipboard.enabled, + clipboard_toast_position: config.ui.toast.clipboard.position, + theme_name: theme_runtime.manual_name.clone(), + theme_runtime, + palette: crate::app::client_palette_from_config(config), + keybinds: config + .live_keybinds_with_diagnostics() + .map(|(keybinds, _diagnostics)| keybinds) + .unwrap_or_else(|_diagnostics| LiveKeybindConfig { + prefix: config.prefix_key(), + keybinds: config.keybinds(), + }), + local_keys: config.keys.clone(), + keybinding_source: ClientShellKeybindingSource::Local, + prompt_new_tab_name: config.ui.prompt_new_tab_name, + prompt_new_workspace_name: config.ui.prompt_new_workspace_name, + confirm_close: config.ui.confirm_close, + mouse_capture: config.ui.mouse_capture, + mouse_scroll_lines: config.ui.mouse_scroll_lines(), + right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(), + redraw_on_focus_gained: config.ui.redraw_on_focus_gained, + switch_ascii_input_source_in_prefix: config + .experimental + .switch_ascii_input_source_in_prefix, + local_config_path: crate::config::config_path(), + preferences_path: None, + preferences: preferences::ClientChromePreferences::default(), + startup_config_diagnostic: None, + startup_onboarding: false, + } + } + + pub(crate) fn with_startup_config_diagnostic(mut self, diagnostic: Option<String>) -> Self { + self.startup_config_diagnostic = diagnostic; + self + } + + pub(crate) fn with_startup_onboarding(mut self, show: bool) -> Self { + self.startup_onboarding = show; + self + } + + pub(crate) fn with_keybinding_source(mut self, source: ClientShellKeybindingSource) -> Self { + self.keybinding_source = source; + self.keybinds.keybinds.custom_commands.clear(); + self + } + + pub(crate) fn uses_endpoint_keybindings(&self) -> bool { + self.keybinding_source == ClientShellKeybindingSource::Endpoint + } + + pub(super) fn local_config_diagnostic(&self, diagnostics: &[String]) -> Option<String> { + if self.uses_endpoint_keybindings() { + crate::config::config_diagnostic_summary_without_keybindings(diagnostics) + } else { + crate::config::config_diagnostic_summary(diagnostics) + } + } + + pub(crate) fn with_local_endpoint(self, socket_path: &std::path::Path) -> Self { + self.with_preferences_path(preferences::path_for_local_endpoint(socket_path)) + } + + pub(super) fn with_preferences_path(mut self, path: std::path::PathBuf) -> Self { + self.preferences = preferences::load(&path).unwrap_or_default(); + self.preferences_path = Some(path); + self + } + + pub(super) fn apply_snapshot_keybindings( + &mut self, + profile: Option<&str>, + commands: &[crate::protocol::ClientShellCommand], + ) -> Result<(), String> { + let mut keybinds = match self.keybinding_source { + ClientShellKeybindingSource::Endpoint => crate::config::keybindings_from_profile_toml( + profile.ok_or("endpoint did not publish its keybindings")?, + )?, + ClientShellKeybindingSource::RemoteLocal => return Ok(()), + ClientShellKeybindingSource::Local => { + let mut config = crate::config::Config { + keys: self.local_keys.clone(), + ..Default::default() + }; + config.keys.command = commands + .iter() + .map(|command| crate::config::CommandKeybindConfig { + key: if command.binding_labels.len() == 1 { + crate::config::BindingConfig::One(command.binding_labels[0].clone()) + } else { + crate::config::BindingConfig::Many(command.binding_labels.clone()) + }, + // The client never executes this field; preserve the opaque endpoint ID + // through the shared config collision resolver. + command: command.command_id.clone(), + action_type: match command.action { + crate::protocol::ClientShellCommandAction::Shell => { + crate::config::CommandKeybindType::Shell + } + crate::protocol::ClientShellCommandAction::Pane => { + crate::config::CommandKeybindType::Pane + } + crate::protocol::ClientShellCommandAction::Popup => { + crate::config::CommandKeybindType::Popup + } + crate::protocol::ClientShellCommandAction::PluginAction => { + crate::config::CommandKeybindType::PluginAction + } + }, + description: command.description.clone(), + width: None, + height: None, + }) + .collect(); + config + .live_keybinds_with_diagnostics() + .map(|(keybinds, _diagnostics)| keybinds) + .map_err(|diagnostics| diagnostics.join("; "))? + } + }; + if self.keybinding_source == ClientShellKeybindingSource::Endpoint { + for command in commands { + keybinds + .keybinds + .custom_commands + .push(crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::from_labels( + &command.binding_labels, + )?, + label: command.binding_label.clone(), + command: command.command_id.clone(), + action: command.action.into(), + description: command.description.clone(), + width: None, + height: None, + }); + } + } + self.keybinds = keybinds; + Ok(()) + } + + pub(super) fn apply_live_config( + &mut self, + config: &Config, + load_diagnostics: &[String], + invalid_sections: &[String], + ) -> Vec<String> { + let mut diagnostics = load_diagnostics.to_vec(); + let invalid_section = + |section: &str| invalid_sections.iter().any(|invalid| invalid == section); + + if !invalid_section("keys") + && self.keybinding_source != ClientShellKeybindingSource::Endpoint + { + match config.live_keybinds_with_diagnostics() { + Ok((mut keybinds, keybind_diagnostics)) => { + self.local_keys = config.keys.clone(); + if self.keybinding_source == ClientShellKeybindingSource::RemoteLocal { + keybinds.keybinds.custom_commands.clear(); + } + self.keybinds = keybinds; + diagnostics.extend(keybind_diagnostics); + } + Err(keybind_diagnostics) => diagnostics.extend( + keybind_diagnostics + .into_iter() + .map(|diagnostic| format!("{diagnostic}; kept current keybinds")), + ), + } + } + + if !invalid_section("ui") { + if let Some(diagnostic) = config.invalid_sidebar_bounds_diagnostic() { + diagnostics.push(format!("{diagnostic}; keeping previous [ui] settings")); + } else { + let ui = &config.ui; + diagnostics.extend(ui.sound.diagnostics()); + self.sidebar_width = ui.sidebar_width; + self.sidebar_min_width = ui.sidebar_min_width; + self.sidebar_max_width = ui.sidebar_max_width; + self.sidebar_collapsed_mode = ui.sidebar_collapsed_mode; + self.mobile_width_threshold = ui.mobile_width_threshold; + self.tab_bar_position = ui.tab_bar_position; + self.hide_tab_bar_when_single_tab = ui.hide_tab_bar_when_single_tab; + self.spaces = ui.sidebar.spaces.clone(); + self.agents = ui.sidebar.agents.clone(); + self.agent_panel_sort = ui.agent_panel_sort; + self.status_indicators = ui.status_indicators; + self.sound_enabled = ui.sound.enabled; + self.toast_delivery = ui.toast.delivery; + self.toast_delay_seconds = ui.toast.delay_seconds; + self.toast_position = ui.toast.herdr.position; + self.copy_on_select = ui.copy_on_select; + self.clipboard_toast_enabled = ui.toast.clipboard.enabled; + self.clipboard_toast_position = ui.toast.clipboard.position; + self.prompt_new_tab_name = ui.prompt_new_tab_name; + self.prompt_new_workspace_name = ui.prompt_new_workspace_name; + self.confirm_close = ui.confirm_close; + self.mouse_capture = ui.mouse_capture; + self.mouse_scroll_lines = ui.mouse_scroll_lines(); + self.right_click_passthrough_modifiers = ui.right_click_passthrough_modifiers(); + self.redraw_on_focus_gained = ui.redraw_on_focus_gained; + } + } + + if !invalid_section("theme") { + self.theme_runtime = crate::app::client_theme_runtime_from_config(config); + self.theme_name = self.theme_runtime.manual_name.clone(); + self.palette = crate::app::client_palette_from_config(config); + } + if !invalid_section("experimental") { + self.switch_ascii_input_source_in_prefix = + config.experimental.switch_ascii_input_source_in_prefix; + } + + diagnostics + } + + pub(super) fn layout( + &self, + cols: u16, + rows: u16, + sidebar_collapsed: bool, + tab_count: usize, + sidebar_width: u16, + ) -> ClientShellLayout { + if cols <= self.mobile_width_threshold { + let header_height = rows.min(2); + return ClientShellLayout { + sidebar: Rect::default(), + tab_bar: Rect::default(), + mobile_header: Rect::new(0, 0, cols, header_height), + pane_surface: Rect::new(0, header_height, cols, rows.saturating_sub(header_height)), + }; + } + + let sidebar_width = if sidebar_collapsed { + match self.sidebar_collapsed_mode { + SidebarCollapsedModeConfig::Compact => 4, + SidebarCollapsedModeConfig::Hidden => 0, + } + } else { + let (min, max) = crate::config::validated_sidebar_bounds( + self.sidebar_min_width, + self.sidebar_max_width, + ) + .unwrap_or((18, 36)); + sidebar_width.clamp(min, max) + } + .min(cols.saturating_sub(1)); + let main = Rect::new(sidebar_width, 0, cols.saturating_sub(sidebar_width), rows); + let show_tab_bar = rows > 1 && !(self.hide_tab_bar_when_single_tab && tab_count == 1); + let tab_height = u16::from(show_tab_bar); + let (tab_bar, pane_surface) = match self.tab_bar_position { + TabBarPositionConfig::Top => ( + Rect::new(main.x, 0, main.width, tab_height), + Rect::new( + main.x, + tab_height, + main.width, + rows.saturating_sub(tab_height), + ), + ), + TabBarPositionConfig::Bottom => ( + Rect::new( + main.x, + rows.saturating_sub(tab_height), + main.width, + tab_height, + ), + Rect::new(main.x, 0, main.width, rows.saturating_sub(tab_height)), + ), + }; + + ClientShellLayout { + sidebar: Rect::new(0, 0, sidebar_width, rows), + tab_bar, + mobile_header: Rect::default(), + pane_surface, + } + } + + pub(crate) fn initial_surface_size(&self, cols: u16, rows: u16) -> ClientSurfaceSize { + let sidebar_collapsed = self + .preferences + .sidebar_collapsed + .unwrap_or(self.sidebar_start_collapsed); + let (min_width, max_width) = + crate::config::validated_sidebar_bounds(self.sidebar_min_width, self.sidebar_max_width) + .unwrap_or((18, 36)); + let sidebar_width = self + .preferences + .sidebar_width + .unwrap_or(self.sidebar_width) + .clamp(min_width, max_width); + let surface = self + .layout(cols, rows, sidebar_collapsed, 0, sidebar_width) + .pane_surface; + ClientSurfaceSize { + cols: surface.width.max(1), + rows: surface.height.max(1), + } + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyModifiers}; + + use super::*; + + #[test] + fn live_reload_applies_client_owned_sections() { + let mut shell = ClientShellConfig::from_config(&Config::default()); + let mut next = Config::default(); + next.ui.sidebar_width = 31; + next.ui.tab_bar_position = TabBarPositionConfig::Bottom; + next.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; + next.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; + next.ui.sidebar.agents.row_gap = 2; + next.keys.prefix = "ctrl+a".to_owned(); + + let diagnostics = shell.apply_live_config(&next, &[], &[]); + + assert!(diagnostics.is_empty()); + assert_eq!(shell.sidebar_width, 31); + assert_eq!(shell.tab_bar_position, TabBarPositionConfig::Bottom); + assert_eq!( + shell.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert_eq!( + shell.status_indicators, + crate::config::StatusIndicatorStyle::Symbols + ); + assert_eq!(shell.agents.row_gap, 2); + assert_eq!( + shell.keybinds.prefix, + (KeyCode::Char('a'), KeyModifiers::CONTROL) + ); + } + + #[test] + fn initial_surface_size_uses_persisted_endpoint_chrome() { + let path = std::env::temp_dir().join(format!( + "herdr-initial-shell-preferences-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + preferences::store( + &path, + preferences::ClientChromePreferences { + sidebar_width: Some(31), + sidebar_collapsed: Some(true), + ..preferences::ClientChromePreferences::default() + }, + ) + .expect("persist endpoint chrome"); + let config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let initial = config.initial_surface_size(100, 30); + let state = ClientShellState::new(config); + assert_eq!(initial, state.surface_size(100, 30)); + std::fs::remove_file(path).expect("remove endpoint chrome"); + } + + #[test] + fn live_reload_preserves_invalid_client_owned_sections() { + let mut initial = Config::default(); + initial.ui.sidebar_width = 29; + initial.keys.prefix = "ctrl+x".to_owned(); + let mut shell = ClientShellConfig::from_config(&initial); + + let mut invalid = Config::default(); + invalid.ui.sidebar_width = 35; + invalid.keys.prefix = "ctrl+a".to_owned(); + let invalid_sections = vec!["ui".to_owned(), "keys".to_owned()]; + shell.apply_live_config(&invalid, &[], &invalid_sections); + + assert_eq!(shell.sidebar_width, 29); + assert_eq!( + shell.keybinds.prefix, + (KeyCode::Char('x'), KeyModifiers::CONTROL) + ); + } +} diff --git a/src/client/shell/context_menu.rs b/src/client/shell/context_menu.rs new file mode 100644 index 00000000..af271bb6 --- /dev/null +++ b/src/client/shell/context_menu.rs @@ -0,0 +1,469 @@ +use super::*; + +impl ClientContextMenuOverlay { + pub(super) fn items(&self) -> Vec<ClientContextMenuItem> { + use ClientContextMenuAction as Action; + + let item = |label, action| ClientContextMenuItem { label, action }; + match &self.target { + ClientContextMenuTarget::Workspace { is_git: false, .. } => { + vec![item("Rename", Action::Rename), item("Close", Action::Close)] + } + ClientContextMenuTarget::Workspace { + is_linked_worktree: false, + has_worktree_children: false, + .. + } => vec![ + item("Rename", Action::Rename), + item("Close", Action::Close), + item("New worktree", Action::NewWorktree), + item("Open worktree...", Action::OpenWorktree), + ], + ClientContextMenuTarget::Workspace { + is_linked_worktree: true, + .. + } => vec![ + item("Rename", Action::Rename), + item("Close", Action::Close), + item("Delete worktree checkout...", Action::RemoveWorktree), + ], + ClientContextMenuTarget::Workspace { + has_worktree_children: true, + collapsed, + .. + } => vec![ + item("Rename", Action::Rename), + item("Close group", Action::Close), + item("New worktree", Action::NewWorktree), + item("Open worktree...", Action::OpenWorktree), + item( + if *collapsed { "Expand" } else { "Collapse" }, + Action::ToggleGroup, + ), + ], + ClientContextMenuTarget::Tab { .. } => vec![ + item("New tab", Action::NewTab), + item("Rename", Action::Rename), + item("Close", Action::Close), + ], + ClientContextMenuTarget::Pane { + source_pane_id, + has_manual_label, + right_click_passthrough, + .. + } => { + let mut items = vec![item("Rename pane", Action::RenamePane)]; + if *has_manual_label { + items.push(item("Clear pane name", Action::ClearPaneName)); + } + if source_pane_id.is_some() { + items.push(item("Swap with focused pane", Action::SwapWithFocusedPane)); + } + items.extend([ + item("Split right", Action::SplitRight), + item("Split down", Action::SplitDown), + item("Zoom", Action::Zoom), + item( + if *right_click_passthrough { + "Use Herdr right-click menu" + } else { + "Send right-clicks to pane" + }, + Action::ToggleRightClickPassthrough, + ), + item("Close pane", Action::ClosePane), + ]); + items + } + } + } +} + +impl ClientShellState { + pub(super) fn open_workspace_context_menu(&mut self, workspace_id: String, x: u16, y: u16) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace) = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + else { + return; + }; + let worktree = workspace.worktree.as_ref(); + let has_worktree_children = worktree.is_some_and(|worktree| { + !worktree.is_linked_worktree + && snapshot + .workspaces + .iter() + .filter(|candidate| { + candidate + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .count() + >= 2 + }); + let collapsed = + worktree.is_some_and(|worktree| self.collapsed_groups.contains(&worktree.key)); + self.overlay = Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Workspace { + workspace_id, + is_git: worktree.is_some() || workspace.branch.is_some(), + is_linked_worktree: worktree.is_some_and(|worktree| worktree.is_linked_worktree), + has_worktree_children, + collapsed, + }, + x, + y, + highlighted: 0, + })); + } + + pub(super) fn open_tab_context_menu(&mut self, tab_id: String, x: u16, y: u16) { + let Some(tab) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id)) + else { + return; + }; + self.overlay = Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Tab { + tab_id, + workspace_id: tab.workspace_id.clone(), + }, + x, + y, + highlighted: 0, + })); + } + + pub(super) fn open_pane_context_menu(&mut self, pane_id: String, x: u16, y: u16) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(pane) = snapshot.panes.iter().find(|pane| pane.pane_id == pane_id) else { + return; + }; + let source_pane_id = snapshot + .focused_pane_id + .clone() + .filter(|focused| focused != &pane_id); + self.overlay = Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Pane { + pane_id, + workspace_id: pane.workspace_id.clone(), + source_pane_id, + has_manual_label: pane.label.is_some(), + right_click_passthrough: pane.right_click_passthrough, + }, + x, + y, + highlighted: 0, + })); + } + + pub(super) fn move_context_menu_selection(&mut self, delta: isize) { + let Some(ClientShellOverlay::ContextMenu(menu)) = self.overlay.as_mut() else { + return; + }; + let item_count = menu.items().len(); + if item_count == 0 { + return; + } + menu.highlighted = (menu.highlighted as isize + delta) + .clamp(0, item_count.saturating_sub(1) as isize) as usize; + } + + pub(super) fn activate_context_menu_item( + &mut self, + index: usize, + outcome: &mut ClientShellInput, + ) { + let Some(ClientShellOverlay::ContextMenu(menu)) = self.overlay.take() else { + return; + }; + let Some(action) = menu.items().get(index).map(|item| item.action) else { + outcome.repaint = true; + return; + }; + match menu.target { + ClientContextMenuTarget::Workspace { workspace_id, .. } => { + self.activate_workspace_context_action(workspace_id, action, outcome) + } + ClientContextMenuTarget::Tab { + tab_id, + workspace_id, + } => self.activate_tab_context_action(tab_id, workspace_id, action, outcome), + ClientContextMenuTarget::Pane { + pane_id, + workspace_id, + source_pane_id, + right_click_passthrough, + .. + } => self.activate_pane_context_action( + pane_id, + workspace_id, + source_pane_id, + right_click_passthrough, + action, + outcome, + ), + } + outcome.repaint = true; + } + + fn activate_workspace_context_action( + &mut self, + workspace_id: String, + action: ClientContextMenuAction, + outcome: &mut ClientShellInput, + ) { + use crate::input::KeybindAction; + + match action { + ClientContextMenuAction::Rename => { + let label = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + }) + .map(|workspace| workspace.label.clone()); + if let Some(label) = label { + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename workspace", + input: label, + replace_on_type: false, + target: ClientRenameTarget::Workspace { workspace_id }, + })); + } + } + ClientContextMenuAction::Close => { + if self.config.confirm_close { + self.open_confirm_close_overlay(workspace_id); + } else { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id, + close_group: true, + }, + ), + outcome, + ); + } + } + ClientContextMenuAction::NewWorktree => { + self.begin_worktree_action_for(KeybindAction::NewWorktree, workspace_id, outcome) + } + ClientContextMenuAction::OpenWorktree => { + self.begin_worktree_action_for(KeybindAction::OpenWorktree, workspace_id, outcome) + } + ClientContextMenuAction::RemoveWorktree => { + self.begin_worktree_action_for(KeybindAction::RemoveWorktree, workspace_id, outcome) + } + ClientContextMenuAction::ToggleGroup => { + let key = self.snapshot.as_deref().and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + .and_then(|workspace| workspace.worktree.as_ref()) + .map(|worktree| worktree.key.clone()) + }); + if let Some(key) = key { + if !self.collapsed_groups.remove(&key) { + self.collapsed_groups.insert(key); + } + self.persist_chrome_preferences(outcome); + } + } + _ => {} + } + } + + fn activate_tab_context_action( + &mut self, + tab_id: String, + workspace_id: String, + action: ClientContextMenuAction, + outcome: &mut ClientShellInput, + ) { + use crate::api::schema::{Method, TabTarget}; + + self.push_endpoint_method( + Method::TabFocus(TabTarget { + tab_id: tab_id.clone(), + }), + outcome, + ); + match action { + ClientContextMenuAction::NewTab => { + if self.config.prompt_new_tab_name { + let default_name = (self + .snapshot + .as_deref() + .map(|snapshot| { + snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .count() + }) + .unwrap_or(0) + + 1) + .to_string(); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "new tab", + input: default_name.clone(), + replace_on_type: true, + target: ClientRenameTarget::NewTab { + workspace_id, + default_name, + }, + })); + } else { + self.push_endpoint_method( + Method::TabCreate(crate::api::schema::TabCreateParams { + workspace_id: Some(workspace_id), + cwd: None, + focus: true, + label: None, + env: Default::default(), + }), + outcome, + ); + } + } + ClientContextMenuAction::Rename => { + let tab = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id)); + if let Some(tab) = tab { + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename tab", + input: tab.label.clone(), + replace_on_type: false, + target: ClientRenameTarget::Tab { + tab_id, + auto_name: !tab.custom_label, + original_name: tab.label.clone(), + }, + })); + } + } + ClientContextMenuAction::Close => { + self.push_endpoint_method(Method::TabClose(TabTarget { tab_id }), outcome); + } + _ => {} + } + } + + fn activate_pane_context_action( + &mut self, + pane_id: String, + workspace_id: String, + source_pane_id: Option<String>, + right_click_passthrough: bool, + action: ClientContextMenuAction, + outcome: &mut ClientShellInput, + ) { + use crate::api::schema::{ + Method, PaneInputSetParams, PaneRenameParams, PaneRightClickTarget, PaneSplitParams, + PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, SplitDirection, + }; + + match action { + ClientContextMenuAction::RenamePane => { + let label = self.snapshot.as_deref().and_then(|snapshot| { + snapshot + .panes + .iter() + .find(|pane| pane.pane_id == pane_id) + .and_then(|pane| pane.label.clone()) + }); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: label.clone().unwrap_or_default(), + replace_on_type: label.is_none(), + target: ClientRenameTarget::Pane { pane_id }, + })); + } + ClientContextMenuAction::ClearPaneName => self.push_endpoint_method( + Method::PaneRename(PaneRenameParams { + pane_id, + label: None, + }), + outcome, + ), + ClientContextMenuAction::SwapWithFocusedPane => { + if let Some(source_pane_id) = source_pane_id { + self.push_endpoint_method( + Method::PaneSwap(PaneSwapParams { + pane_id: None, + direction: None, + source_pane_id: Some(source_pane_id.clone()), + target_pane_id: Some(pane_id), + }), + outcome, + ); + self.push_endpoint_method( + Method::PaneFocus(PaneTarget { + pane_id: source_pane_id, + }), + outcome, + ); + } + } + ClientContextMenuAction::SplitRight | ClientContextMenuAction::SplitDown => { + self.push_endpoint_method( + Method::PaneSplit(PaneSplitParams { + workspace_id: Some(workspace_id), + target_pane_id: Some(pane_id), + direction: if action == ClientContextMenuAction::SplitRight { + SplitDirection::Right + } else { + SplitDirection::Down + }, + ratio: None, + cwd: None, + focus: true, + right_click: Default::default(), + env: Default::default(), + }), + outcome, + ); + } + ClientContextMenuAction::Zoom => self.push_endpoint_method( + Method::PaneZoom(PaneZoomParams { + pane_id: Some(pane_id), + mode: PaneZoomMode::Toggle, + }), + outcome, + ), + ClientContextMenuAction::ToggleRightClickPassthrough => self.push_endpoint_method( + Method::PaneInputSet(PaneInputSetParams { + pane_id, + right_click: if right_click_passthrough { + PaneRightClickTarget::Herdr + } else { + PaneRightClickTarget::Pane + }, + }), + outcome, + ), + ClientContextMenuAction::ClosePane => { + self.push_endpoint_method(Method::PaneClose(PaneTarget { pane_id }), outcome) + } + _ => {} + } + } +} diff --git a/src/client/shell/copy_mode.rs b/src/client/shell/copy_mode.rs new file mode 100644 index 00000000..c39a7507 --- /dev/null +++ b/src/client/shell/copy_mode.rs @@ -0,0 +1,871 @@ +use super::*; +use crossterm::event::{KeyCode, KeyModifiers}; + +impl ClientShellState { + pub(super) fn reset_copy_pipeline(&mut self) { + self.copy_session_generation = self.copy_session_generation.saturating_add(1); + self.copy_operation_in_flight = false; + self.copy_operation_queue.clear(); + self.copy_input_queue.clear(); + } + + pub(super) fn enter_copy_mode(&mut self, outcome: &mut ClientShellInput) -> bool { + let pane_id = match self.focused_pane_id() { + Some(pane_id) => pane_id, + None => return false, + }; + if self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.pane_id == pane_id) + { + self.mode = ClientShellMode::Copy; + return true; + } + if self.copy_mode.is_some() { + self.exit_copy_mode(false, outcome); + } + let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| hit.pane_id == pane_id) + .cloned() + else { + return false; + }; + let Some(metrics) = hit.scroll else { + return false; + }; + let viewport_top = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom) + .min(u32::MAX as usize) as u32; + let cursor = self + .pane_surface + .as_ref() + .and_then(|surface| { + let pane = surface.panes.iter().find(|pane| pane.pane_id == pane_id)?; + let cursor = surface + .frame + .cursor + .as_ref() + .filter(|cursor| cursor.visible)?; + let inner = pane.inner_rect; + (cursor.x >= inner.x + && cursor.x < inner.x.saturating_add(inner.width) + && cursor.y >= inner.y + && cursor.y < inner.y.saturating_add(inner.height)) + .then_some(crate::api::schema::PaneTextPoint { + row: viewport_top.saturating_add(u32::from(cursor.y - inner.y)), + col: cursor.x - inner.x, + }) + }) + .unwrap_or(crate::api::schema::PaneTextPoint { + row: viewport_top + .saturating_add(u32::from(hit.inner_rect.height.saturating_sub(1))), + col: 0, + }); + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + self.reset_copy_pipeline(); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| surface.panes.iter().find(|pane| pane.pane_id == pane_id)) + .map_or(0, |pane| pane.content_revision); + self.copy_mode = Some(ClientCopyModeState { + pane_id, + content_revision, + geometry: (hit.inner_rect.width, hit.inner_rect.height), + cursor, + offset_from_bottom: metrics.offset_from_bottom, + max_offset_from_bottom: metrics.max_offset_from_bottom, + entry_offset_from_bottom: metrics.offset_from_bottom, + selection: None, + search_prompt: None, + search_query: String::new(), + search_direction: None, + search_matches: Vec::new(), + search_total: 0, + search_current: None, + search_current_global: None, + search_generation: 0, + copy_after_search: false, + }); + self.mode = ClientShellMode::Copy; + true + } + + pub(super) fn route_copy_mode_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + if self.route_copy_search_prompt_key(key, outcome) { + return; + } + match key.code { + KeyCode::Esc => { + let should_clear = self.copy_mode.as_ref().is_some_and(|copy_mode| { + copy_mode.selection.is_some() + || !copy_mode.search_query.is_empty() + || !copy_mode.search_matches.is_empty() + || copy_mode.search_direction.is_some() + }); + if should_clear { + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.selection = None; + copy_mode.search_query.clear(); + copy_mode.search_direction = None; + copy_mode.search_matches.clear(); + copy_mode.search_total = 0; + copy_mode.search_current = None; + copy_mode.search_current_global = None; + copy_mode.search_generation = copy_mode.search_generation.saturating_add(1); + copy_mode.copy_after_search = false; + } + self.selection = None; + } else { + self.exit_copy_mode(false, outcome); + } + outcome.repaint = true; + return; + } + KeyCode::Enter => { + if !self.defer_copy_until_search_result() { + self.exit_copy_mode(true, outcome); + } + return; + } + KeyCode::Left => { + self.move_copy_cursor(0, -1, outcome); + return; + } + KeyCode::Down => { + self.move_copy_cursor(1, 0, outcome); + return; + } + KeyCode::Up => { + self.move_copy_cursor(-1, 0, outcome); + return; + } + KeyCode::Right => { + self.move_copy_cursor(0, 1, outcome); + return; + } + KeyCode::PageUp => { + self.move_copy_page(-1, false, outcome); + return; + } + KeyCode::PageDown => { + self.move_copy_page(1, false, outcome); + return; + } + KeyCode::Home => { + self.set_copy_cursor_col(0); + self.sync_copy_selection(); + outcome.repaint = true; + return; + } + KeyCode::End => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::LineEnd, outcome); + return; + } + _ => {} + } + + match (key.code, key.modifiers) { + (KeyCode::Char('b'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.move_copy_page(-1, false, outcome); + return; + } + (KeyCode::Char('f'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.move_copy_page(1, false, outcome); + return; + } + (KeyCode::Char('u'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.move_copy_page(-1, true, outcome); + return; + } + (KeyCode::Char('d'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.move_copy_page(1, true, outcome); + return; + } + _ => {} + } + + let Some(command) = crate::copy_mode::copy_mode_command_char(key.clone()) else { + return; + }; + match command { + 'q' => self.exit_copy_mode(false, outcome), + 'y' => { + if !self.defer_copy_until_search_result() { + self.exit_copy_mode(true, outcome); + } + } + 'v' | ' ' => self.begin_copy_selection(false), + 'V' => self.begin_copy_selection(true), + 'h' => self.move_copy_cursor(0, -1, outcome), + 'j' => self.move_copy_cursor(1, 0, outcome), + 'k' => self.move_copy_cursor(-1, 0, outcome), + 'l' => self.move_copy_cursor(0, 1, outcome), + 'g' => self.move_copy_history(true, outcome), + 'G' => self.move_copy_history(false, outcome), + '0' => { + self.set_copy_cursor_col(0); + self.sync_copy_selection(); + outcome.repaint = true; + } + '$' => self.request_copy_motion(crate::api::schema::PaneCopyMotion::LineEnd, outcome), + '^' => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::FirstNonBlank, outcome) + } + '/' => self.open_copy_search(crate::api::schema::PaneCopySearchDirection::Forward), + '?' => self.open_copy_search(crate::api::schema::PaneCopySearchDirection::Backward), + 'n' => self.repeat_copy_search(false, outcome), + 'N' => self.repeat_copy_search(true, outcome), + 'w' => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::NextWordStart, outcome) + } + 'b' => self.request_copy_motion( + crate::api::schema::PaneCopyMotion::PreviousWordStart, + outcome, + ), + 'e' => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::NextWordEnd, outcome) + } + 'W' => self.request_copy_motion( + crate::api::schema::PaneCopyMotion::NextBigWordStart, + outcome, + ), + 'B' => self.request_copy_motion( + crate::api::schema::PaneCopyMotion::PreviousBigWordStart, + outcome, + ), + 'E' => self + .request_copy_motion(crate::api::schema::PaneCopyMotion::NextBigWordEnd, outcome), + '{' => self.request_copy_motion( + crate::api::schema::PaneCopyMotion::PreviousParagraph, + outcome, + ), + '}' => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::NextParagraph, outcome) + } + _ => return, + } + outcome.repaint = true; + } + + fn route_copy_search_prompt_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> bool { + let Some(prompt) = self + .copy_mode + .as_ref() + .and_then(|copy_mode| copy_mode.search_prompt.as_ref()) + else { + return false; + }; + let mut submit = None; + match key.code { + KeyCode::Esc => { + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.search_prompt = None; + } + } + KeyCode::Enter => { + submit = Some((prompt.query.clone(), prompt.direction)); + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.search_prompt = None; + } + } + KeyCode::Backspace => { + if let Some(prompt) = self + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) + { + prompt.query.pop(); + } + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(prompt) = self + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) + { + prompt.query.clear(); + } + } + _ => { + if let Some(ch) = crate::copy_mode::copy_mode_command_char(key.clone()) { + if let Some(prompt) = self + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) + { + prompt.query.push(ch); + } + } + } + } + if let Some((query, direction)) = submit { + self.request_copy_search(query, direction, false, outcome); + } + outcome.repaint = true; + true + } + + pub(super) fn insert_copy_search_text(&mut self, text: &str) -> bool { + let Some(prompt) = self + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) + else { + return false; + }; + prompt + .query + .extend(text.chars().filter(|character| !character.is_control())); + true + } + + fn open_copy_search(&mut self, direction: crate::api::schema::PaneCopySearchDirection) { + let Some(copy_mode) = self.copy_mode.as_mut() else { + return; + }; + copy_mode.search_prompt = Some(ClientCopySearchPrompt { + direction, + query: String::new(), + }); + } + + fn repeat_copy_search(&mut self, reverse: bool, outcome: &mut ClientShellInput) { + let Some(copy_mode) = self.copy_mode.as_ref() else { + return; + }; + if copy_mode.search_query.is_empty() { + return; + } + let Some(direction) = copy_mode.search_direction else { + return; + }; + let direction = if reverse { + match direction { + crate::api::schema::PaneCopySearchDirection::Forward => { + crate::api::schema::PaneCopySearchDirection::Backward + } + crate::api::schema::PaneCopySearchDirection::Backward => { + crate::api::schema::PaneCopySearchDirection::Forward + } + } + } else { + direction + }; + self.request_copy_search(copy_mode.search_query.clone(), direction, true, outcome); + } + + fn defer_copy_until_search_result(&mut self) -> bool { + let Some(copy_mode) = self.copy_mode.as_ref() else { + return false; + }; + let pane_id = copy_mode.pane_id.clone(); + let generation = copy_mode.search_generation; + let pending = self.pending_requests.values().any(|pending| { + matches!( + &pending.kind, + PendingEndpointKind::CopySearch { + pane_id: pending_pane, + generation: pending_generation, + .. + } if pending_pane == &pane_id && *pending_generation == generation + ) + }) || self + .copy_operation_queue + .iter() + .any(|operation| matches!(operation, ClientCopyOperation::Search { .. })); + if pending { + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.copy_after_search = true; + } + } + pending + } + + fn request_copy_search( + &mut self, + query: String, + direction: crate::api::schema::PaneCopySearchDirection, + repeat: bool, + outcome: &mut ClientShellInput, + ) { + if query.is_empty() || self.copy_mode.is_none() { + return; + } + self.copy_operation_queue + .push_back(ClientCopyOperation::Search { + query, + direction, + repeat, + }); + self.dispatch_next_copy_operation(outcome); + } + + pub(super) fn apply_copy_search_result( + &mut self, + pane_id: &str, + origin: crate::api::schema::PaneTextPoint, + query: String, + direction: crate::api::schema::PaneCopySearchDirection, + repeat: bool, + generation: u64, + result: ClientCopySearchResult, + outcome: &mut ClientShellInput, + ) -> bool { + let search_queued = self + .copy_operation_queue + .iter() + .any(|operation| matches!(operation, ClientCopyOperation::Search { .. })); + let Some(copy_mode) = self.copy_mode.as_mut() else { + return false; + }; + if copy_mode.pane_id != pane_id + || copy_mode.cursor != origin + || copy_mode.content_revision != result.content_revision + || copy_mode.search_generation != generation + { + return false; + } + let current = result.current.filter(|index| *index < result.matches.len()); + copy_mode.search_query = query; + if !repeat { + copy_mode.search_direction = Some(direction); + } + copy_mode.search_matches = result.matches; + copy_mode.search_total = result.total; + copy_mode.search_current = current; + copy_mode.search_current_global = result.current_global; + let target = current.and_then(|index| copy_mode.search_matches.get(index).copied()); + let copy_after_search = if search_queued { + false + } else { + std::mem::take(&mut copy_mode.copy_after_search) + }; + if let Some(target) = target { + copy_mode.cursor = target.start; + self.reveal_copy_cursor(outcome, true); + self.sync_copy_selection(); + } + if copy_after_search { + self.exit_copy_mode(true, outcome); + } + outcome.repaint = true; + true + } + + pub(super) fn complete_copy_operation( + &mut self, + session_generation: u64, + continue_queue: bool, + outcome: &mut ClientShellInput, + ) { + if self.copy_session_generation != session_generation { + return; + } + self.copy_operation_in_flight = false; + if continue_queue && self.copy_mode.is_some() { + self.dispatch_next_copy_operation(outcome); + self.dispatch_queued_copy_input(outcome); + } else { + self.copy_operation_queue.clear(); + self.copy_input_queue.clear(); + } + } + + fn dispatch_queued_copy_input(&mut self, outcome: &mut ClientShellInput) { + while !self.copy_operation_in_flight { + let Some(key) = self.copy_input_queue.pop_front() else { + return; + }; + self.handle_key(key, outcome); + } + } + + pub(super) fn cancel_deferred_copy_after_search(&mut self, generation: u64) { + if let Some(copy_mode) = self + .copy_mode + .as_mut() + .filter(|copy_mode| copy_mode.search_generation == generation) + { + copy_mode.copy_after_search = false; + } + } + + fn copy_hit(&self) -> Option<PaneHit> { + let pane_id = self.copy_mode.as_ref()?.pane_id.as_str(); + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == pane_id) + .cloned() + } + + fn move_copy_cursor(&mut self, row_delta: i16, col_delta: i16, outcome: &mut ClientShellInput) { + let Some(hit) = self.copy_hit() else { + self.exit_copy_mode(false, outcome); + return; + }; + let Some(copy_mode) = self.copy_mode.as_mut() else { + return; + }; + if col_delta < 0 { + copy_mode.cursor.col = copy_mode + .cursor + .col + .saturating_sub(col_delta.unsigned_abs()); + } else if col_delta > 0 { + copy_mode.cursor.col = copy_mode + .cursor + .col + .saturating_add(col_delta as u16) + .min(hit.inner_rect.width.saturating_sub(1)); + } + let total_rows = copy_mode + .max_offset_from_bottom + .saturating_add(hit.inner_rect.height as usize) + .max(1); + if row_delta < 0 { + copy_mode.cursor.row = copy_mode + .cursor + .row + .saturating_sub(u32::from(row_delta.unsigned_abs())); + } else if row_delta > 0 { + copy_mode.cursor.row = copy_mode + .cursor + .row + .saturating_add(u32::from(row_delta as u16)) + .min(total_rows.saturating_sub(1).min(u32::MAX as usize) as u32); + } + self.reveal_copy_cursor(outcome, false); + self.sync_copy_selection(); + outcome.repaint = true; + } + + fn move_copy_page(&mut self, direction: i8, half_page: bool, outcome: &mut ClientShellInput) { + let Some(hit) = self.copy_hit() else { + return; + }; + let lines = crate::copy_mode::copy_mode_page_lines(hit.inner_rect.height, half_page); + let Some((pane_id, next_offset)) = self.copy_mode.as_mut().map(|copy_mode| { + if direction < 0 { + copy_mode.cursor.row = copy_mode.cursor.row.saturating_sub(lines as u32); + copy_mode.offset_from_bottom = copy_mode + .offset_from_bottom + .saturating_add(lines) + .min(copy_mode.max_offset_from_bottom); + } else { + let last_row = copy_mode + .max_offset_from_bottom + .saturating_add(hit.inner_rect.height as usize) + .saturating_sub(1) + .min(u32::MAX as usize) as u32; + copy_mode.cursor.row = copy_mode + .cursor + .row + .saturating_add(lines as u32) + .min(last_row); + copy_mode.offset_from_bottom = copy_mode.offset_from_bottom.saturating_sub(lines); + } + (copy_mode.pane_id.clone(), copy_mode.offset_from_bottom) + }) else { + return; + }; + self.push_pane_scroll_offset(pane_id, next_offset, outcome); + self.sync_copy_selection(); + outcome.repaint = true; + } + + fn move_copy_history(&mut self, top: bool, outcome: &mut ClientShellInput) { + let Some(hit) = self.copy_hit() else { + return; + }; + let Some((pane_id, offset_from_bottom)) = self.copy_mode.as_mut().map(|copy_mode| { + if top { + copy_mode.cursor.row = 0; + copy_mode.offset_from_bottom = copy_mode.max_offset_from_bottom; + } else { + copy_mode.cursor.row = copy_mode + .max_offset_from_bottom + .saturating_add(hit.inner_rect.height as usize) + .saturating_sub(1) + .min(u32::MAX as usize) as u32; + copy_mode.offset_from_bottom = 0; + } + (copy_mode.pane_id.clone(), copy_mode.offset_from_bottom) + }) else { + return; + }; + self.push_pane_scroll_offset(pane_id, offset_from_bottom, outcome); + self.sync_copy_selection(); + outcome.repaint = true; + } + + fn set_copy_cursor_col(&mut self, col: u16) { + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.cursor.col = col; + } + } + + fn reveal_copy_cursor(&mut self, outcome: &mut ClientShellInput, reserve_mode_bar_row: bool) { + let Some(hit) = self.copy_hit() else { + return; + }; + let request = self.copy_mode.as_mut().and_then(|copy_mode| { + let current_top = copy_mode + .max_offset_from_bottom + .saturating_sub(copy_mode.offset_from_bottom) as u32; + let max_cursor_row = hit + .inner_rect + .height + .saturating_sub(if reserve_mode_bar_row { 2 } else { 1 }); + let bottom = current_top.saturating_add(u32::from(max_cursor_row)); + let desired_top = if copy_mode.cursor.row < current_top { + copy_mode.cursor.row + } else if copy_mode.cursor.row > bottom { + copy_mode + .cursor + .row + .saturating_sub(u32::from(max_cursor_row)) + } else { + current_top + }; + let offset = copy_mode + .max_offset_from_bottom + .saturating_sub(desired_top as usize); + if offset == copy_mode.offset_from_bottom { + return None; + } + copy_mode.offset_from_bottom = offset; + Some((copy_mode.pane_id.clone(), offset)) + }); + if let Some((pane_id, offset)) = request { + self.push_pane_scroll_offset(pane_id, offset, outcome); + } + } + + fn begin_copy_selection(&mut self, linewise: bool) { + let end_col = self + .copy_hit() + .map_or(0, |hit| hit.inner_rect.width.saturating_sub(1)); + let Some(copy_mode) = self.copy_mode.as_mut() else { + return; + }; + if linewise { + copy_mode.selection = Some(ClientCopySelection::Linewise { + anchor_row: copy_mode.cursor.row, + }); + self.selection = Some(crate::selection::Selection::line_range( + copy_mode.pane_id.clone(), + copy_mode.cursor.row, + copy_mode.cursor.row, + end_col, + )); + } else { + copy_mode.selection = Some(ClientCopySelection::Character { + anchor: copy_mode.cursor, + }); + self.selection = Some(crate::selection::Selection::absolute_anchor( + copy_mode.pane_id.clone(), + (copy_mode.cursor.row, copy_mode.cursor.col), + )); + } + } + + pub(super) fn sync_copy_selection(&mut self) { + let Some(copy_mode) = self.copy_mode.as_ref() else { + return; + }; + let Some(selection) = copy_mode.selection else { + return; + }; + self.selection = Some(match selection { + ClientCopySelection::Character { anchor } => { + crate::selection::Selection::absolute_range( + copy_mode.pane_id.clone(), + (anchor.row, anchor.col), + (copy_mode.cursor.row, copy_mode.cursor.col), + ) + } + ClientCopySelection::Linewise { anchor_row } => { + crate::selection::Selection::line_range( + copy_mode.pane_id.clone(), + anchor_row, + copy_mode.cursor.row, + self.copy_hit() + .map_or(0, |hit| hit.inner_rect.width.saturating_sub(1)), + ) + } + }); + } + + fn request_copy_motion( + &mut self, + motion: crate::api::schema::PaneCopyMotion, + outcome: &mut ClientShellInput, + ) { + if self.copy_mode.is_none() { + return; + } + self.copy_operation_queue + .push_back(ClientCopyOperation::Motion(motion)); + self.dispatch_next_copy_operation(outcome); + } + + pub(super) fn dispatch_next_copy_operation(&mut self, outcome: &mut ClientShellInput) { + if self.copy_operation_in_flight { + return; + } + while let Some(operation) = self.copy_operation_queue.pop_front() { + let Some(copy_mode) = self.copy_mode.as_mut() else { + self.copy_operation_queue.clear(); + self.copy_input_queue.clear(); + return; + }; + let session_generation = self.copy_session_generation; + let pane_id = copy_mode.pane_id.clone(); + let origin = copy_mode.cursor; + let (method, kind) = match operation { + ClientCopyOperation::Motion(motion) => ( + crate::api::schema::Method::PaneCopyMotion( + crate::api::schema::PaneCopyMotionParams { + pane_id: pane_id.clone(), + cursor: origin, + motion, + content_revision: Some(copy_mode.content_revision), + }, + ), + PendingEndpointKind::CopyMotion { + pane_id, + origin, + session_generation, + }, + ), + ClientCopyOperation::Search { + query, + direction, + repeat, + } => { + if query.is_empty() { + continue; + } + copy_mode.search_generation = copy_mode.search_generation.saturating_add(1); + let generation = copy_mode.search_generation; + let previous = repeat + .then(|| { + copy_mode + .search_current + .and_then(|index| copy_mode.search_matches.get(index).copied()) + .filter(|text_match| text_match.start == copy_mode.cursor) + }) + .flatten(); + ( + crate::api::schema::Method::PaneCopySearch( + crate::api::schema::PaneCopySearchParams { + pane_id: pane_id.clone(), + query: query.clone(), + direction, + cursor: origin, + content_revision: copy_mode.content_revision, + previous, + }, + ), + PendingEndpointKind::CopySearch { + pane_id, + origin, + query, + direction, + repeat, + generation, + session_generation, + }, + ) + } + }; + self.copy_operation_in_flight = true; + self.push_endpoint_method_with_kind(method, kind, outcome); + return; + } + } + + pub(super) fn apply_copy_motion_target( + &mut self, + pane_id: &str, + origin: crate::api::schema::PaneTextPoint, + cursor: crate::api::schema::PaneTextPoint, + content_revision: u64, + outcome: &mut ClientShellInput, + ) -> bool { + let Some(copy_mode) = self.copy_mode.as_mut() else { + return false; + }; + if copy_mode.pane_id != pane_id + || copy_mode.cursor != origin + || copy_mode.content_revision != content_revision + { + return false; + } + copy_mode.cursor = cursor; + self.reveal_copy_cursor(outcome, false); + self.sync_copy_selection(); + outcome.repaint = true; + true + } + + pub(super) fn exit_copy_mode(&mut self, copy: bool, outcome: &mut ClientShellInput) { + if copy + && !self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible) + { + if let Some((pane_id, text_match)) = self.copy_mode.as_ref().and_then(|copy_mode| { + copy_mode + .search_current + .and_then(|index| copy_mode.search_matches.get(index).copied()) + .map(|text_match| (copy_mode.pane_id.clone(), text_match)) + }) { + self.selection = Some(crate::selection::Selection::absolute_range( + pane_id, + (text_match.start.row, text_match.start.col), + (text_match.end.row, text_match.end.col), + )); + } + } + let Some(copy_mode) = self.copy_mode.take() else { + return; + }; + self.reset_copy_pipeline(); + if copy + && self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible) + { + self.request_selection_copy(outcome); + } + self.selection = None; + self.selection_highlight_clear_deadline = None; + self.push_pane_scroll_offset( + copy_mode.pane_id, + copy_mode.entry_offset_from_bottom, + outcome, + ); + self.mode = ClientShellMode::Terminal; + outcome.repaint = true; + } +} diff --git a/src/client/shell/global_menu.rs b/src/client/shell/global_menu.rs new file mode 100644 index 00000000..e5bd6f78 --- /dev/null +++ b/src/client/shell/global_menu.rs @@ -0,0 +1,110 @@ +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ClientGlobalMenuAction { + Binding(crate::input::KeybindAction), + WhatsNew, +} + +pub(super) fn global_menu_attention(snapshot: &ClientShellSnapshot) -> bool { + snapshot.update_available.is_some() || snapshot.integration_updates_available +} + +pub(super) fn global_menu_item_has_badge( + snapshot: &ClientShellSnapshot, + action: ClientGlobalMenuAction, +) -> bool { + (action == ClientGlobalMenuAction::WhatsNew && snapshot.update_available.is_some()) + || (action == ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Settings) + && snapshot.integration_updates_available) +} + +pub(super) fn global_menu_items( + snapshot: &ClientShellSnapshot, +) -> Vec<(&'static str, ClientGlobalMenuAction)> { + let mut items = vec![ + ( + "settings", + ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Settings), + ), + ( + "keybinds", + ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Help), + ), + ( + "reload config", + ClientGlobalMenuAction::Binding(crate::input::KeybindAction::ReloadConfig), + ), + ]; + if snapshot.update_available.is_some() || snapshot.latest_release_notes_available { + items.push(( + if snapshot.update_available.is_some() { + "update ready" + } else { + "what's new" + }, + ClientGlobalMenuAction::WhatsNew, + )); + } + items.push(( + "detach", + ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Detach), + )); + items +} + +impl ClientShellState { + pub(super) fn toggle_global_menu(&mut self) { + if matches!(self.overlay, Some(ClientShellOverlay::GlobalMenu(_))) { + self.overlay = None; + } else { + self.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 0, + })); + } + } + + pub(super) fn move_global_menu_selection(&mut self, delta: isize) { + let item_count = self + .snapshot + .as_deref() + .map(global_menu_items) + .map_or(0, |items| items.len()); + let Some(ClientShellOverlay::GlobalMenu(menu)) = self.overlay.as_mut() else { + return; + }; + menu.highlighted = (menu.highlighted as isize + delta) + .clamp(0, item_count.saturating_sub(1) as isize) as usize; + } + + pub(super) fn activate_global_menu_item( + &mut self, + index: usize, + outcome: &mut ClientShellInput, + ) { + let Some(action) = self.snapshot.as_deref().and_then(|snapshot| { + global_menu_items(snapshot) + .get(index) + .map(|(_, action)| *action) + }) else { + return; + }; + if action == ClientGlobalMenuAction::WhatsNew + && self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.release_notes.as_ref()) + .is_none() + { + return; + } + self.overlay = None; + match action { + ClientGlobalMenuAction::Binding(binding) => { + self.record_binding(crate::input::KeybindMatch::Action(binding), outcome) + } + ClientGlobalMenuAction::WhatsNew => self.open_release_notes(), + } + outcome.repaint = true; + } +} diff --git a/src/client/shell/graphics.rs b/src/client/shell/graphics.rs new file mode 100644 index 00000000..1fc23414 --- /dev/null +++ b/src/client/shell/graphics.rs @@ -0,0 +1,67 @@ +use super::*; + +impl ClientShellState { + #[cfg(unix)] + pub(crate) fn graphics_scope(&self) -> &str { + self.snapshot + .as_deref() + .map(|snapshot| snapshot.boot_id.as_str()) + .unwrap_or_default() + } + + #[cfg(unix)] + pub(crate) fn trust_direct_graphics_asset( + &mut self, + key: &crate::protocol::SurfaceGraphicsAssetKey, + image_id: u32, + ) -> bool { + self.graphics.trust_direct_asset(key, image_id) + } + + #[cfg(unix)] + pub(crate) fn retire_direct_graphics_image(&mut self, image_id: u32) { + self.graphics.retire_direct_image(image_id); + } + + pub(crate) fn take_pending_graphics_cleanup(&mut self) -> Vec<u8> { + self.graphics.take_pending_cleanup() + } + + pub(crate) fn set_graphics_cell_size(&mut self, width_px: u32, height_px: u32) { + self.graphics_cell_size = crate::kitty_graphics::HostCellSize { + width_px: width_px.max(1), + height_px: height_px.max(1), + }; + } + + pub(super) fn compose_graphics(&mut self, frame: &mut FrameData, layout: ClientShellLayout) { + let local_cover = self.overlay.is_some() + || self.mode != ClientShellMode::Terminal + || self.endpoint_error.is_some() + || self.config_diagnostic.is_some() + || self.visible_notification.is_some() + || self.copy_feedback.is_some() + || self + .selection + .as_ref() + .is_some_and(|selection| selection.is_visible()); + let visibility = if local_cover { + crate::kitty_graphics::surface::Visibility::Hidden + } else if self.hits.popup.is_some() { + crate::kitty_graphics::surface::Visibility::Popup + } else { + crate::kitty_graphics::surface::Visibility::Main + }; + let popup_origin = self + .hits + .popup + .as_ref() + .map(|popup| (popup.inner_rect.x, popup.inner_rect.y)); + frame.graphics = self.graphics.encode( + visibility, + (layout.pane_surface.x, layout.pane_surface.y), + popup_origin, + self.graphics_cell_size, + ); + } +} diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs new file mode 100644 index 00000000..d3af1480 --- /dev/null +++ b/src/client/shell/input.rs @@ -0,0 +1,1011 @@ +use super::*; +use crate::protocol::ClientPaneInputEvent; +use crate::raw_input::RawInputEvent; +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; + +const LOCAL_INPUT_SOURCE: u8 = 0; + +fn is_retained_selection_copy_key(key: &crate::input::TerminalKey) -> bool { + matches!(key.code, KeyCode::Char('c' | 'C')) + && matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER) +} + +pub(super) fn is_modal_paste_shortcut_for_platform( + key: &crate::input::TerminalKey, + macos: bool, +) -> bool { + matches!(key.code, KeyCode::Char('v' | 'V')) + && (key.modifiers.contains(KeyModifiers::CONTROL) + || macos && key.modifiers.contains(KeyModifiers::SUPER)) +} + +fn is_modal_paste_shortcut(key: &crate::input::TerminalKey) -> bool { + is_modal_paste_shortcut_for_platform(key, cfg!(target_os = "macos")) +} + +fn host_theme_update(event: &RawInputEvent) -> Option<crate::protocol::ClientHostThemeUpdate> { + use crate::protocol::{ + ClientHostAppearance, ClientHostDefaultColorKind, ClientHostThemeUpdate, + }; + + match event { + RawInputEvent::HostDefaultColor { kind, color } => { + Some(ClientHostThemeUpdate::DefaultColor { + kind: match kind { + crate::terminal_theme::DefaultColorKind::Foreground => { + ClientHostDefaultColorKind::Foreground + } + crate::terminal_theme::DefaultColorKind::Background => { + ClientHostDefaultColorKind::Background + } + }, + color: (*color).into(), + }) + } + RawInputEvent::HostPaletteColors { colors } => Some(ClientHostThemeUpdate::PaletteColors( + colors + .iter() + .map(|(index, color)| (*index, (*color).into())) + .collect(), + )), + RawInputEvent::HostColorSchemeChanged(appearance) => { + Some(ClientHostThemeUpdate::Appearance(match appearance { + crate::terminal_theme::HostAppearance::Dark => ClientHostAppearance::Dark, + crate::terminal_theme::HostAppearance::Light => ClientHostAppearance::Light, + })) + } + _ => None, + } +} + +impl ClientShellState { + pub(crate) fn host_keyboard_report_all_requested(&self) -> bool { + matches!( + self.mode, + ClientShellMode::Prefix | ClientShellMode::Navigate + ) + } + + #[cfg(any(unix, test))] + pub(crate) fn handle_input_bytes(&mut self, data: &[u8]) -> ClientShellInput { + self.handle_raw_events(crate::raw_input::parse_raw_input_bytes_sync(data)) + } + + #[cfg(any(unix, test))] + pub(crate) fn handle_pixel_mouse( + &mut self, + data: &[u8], + geometry: crate::input::mouse::HostGeometry, + ) -> ClientShellInput { + let Some((x, y)) = crate::input::mouse::parse_report(data) else { + return ClientShellInput::default(); + }; + let Some((column, row)) = geometry.cell(x, y) else { + return ClientShellInput::default(); + }; + let Some(cell_report) = crate::input::mouse::report_at_cell(data, column, row) else { + return ClientShellInput::default(); + }; + let events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); + if events.len() != 1 || !matches!(events[0], RawInputEvent::Mouse(_)) { + return ClientShellInput::default(); + } + self.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry }); + let outcome = self.handle_raw_events(events); + self.host_mouse_pixels = None; + outcome + } + + #[cfg(windows)] + pub(crate) fn handle_client_events( + &mut self, + events: &[crate::protocol::ClientInputEvent], + ) -> ClientShellInput { + self.handle_raw_events( + events + .iter() + .map(crate::protocol::ClientInputEvent::to_raw_input_event) + .collect(), + ) + } + + fn prepare_committed_text(&mut self, text: &str, outcome: &mut ClientShellInput) -> bool { + if self.insert_copy_search_text(text) { + outcome.repaint = true; + return true; + } + self.pending_word_selection = None; + if self.copy_or_terminal_mode() != ClientShellMode::Copy && self.selection.take().is_some() + { + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + outcome.repaint = true; + } + false + } + + pub(crate) fn replay_mouse_events( + &mut self, + events: Vec<crossterm::event::MouseEvent>, + ) -> ClientShellInput { + self.replaying_url_click = true; + let outcome = + self.handle_raw_events(events.into_iter().map(RawInputEvent::Mouse).collect()); + self.replaying_url_click = false; + outcome + } + + pub(super) fn handle_raw_events(&mut self, events: Vec<RawInputEvent>) -> ClientShellInput { + let mut outcome = ClientShellInput::default(); + if !events.is_empty() && self.endpoint_error.take().is_some() { + outcome.repaint = true; + } + for event in events { + if let Some(update) = host_theme_update(&event) { + outcome + .requests + .push(ClientMessage::ClientShellHostTheme { update }); + } + match event { + RawInputEvent::Key(key) => self.handle_key(key, &mut outcome), + RawInputEvent::Text(text) => { + let text = text.into_string(); + if matches!( + self.overlay, + Some( + ClientShellOverlay::Onboarding + | ClientShellOverlay::ProductAnnouncement(_) + | ClientShellOverlay::ReleaseNotes(_) + ) + ) { + self.reconcile_input_source(); + continue; + } + if self.prepare_committed_text(&text, &mut outcome) { + self.reconcile_input_source(); + continue; + } + if let Some(target) = self.popup_input_target() { + super::push_target_event( + target, + ClientPaneInputEvent::TextCommit(text), + &mut outcome, + ); + } else if !self.popup_pending { + if self.insert_overlay_text(&text) { + outcome.repaint = true; + } else if self.overlay.is_none() && self.mode == ClientShellMode::Terminal { + self.push_focused_pane_event( + ClientPaneInputEvent::TextCommit(text), + &mut outcome, + ); + } + } + } + RawInputEvent::Paste(text) => { + if matches!( + self.overlay, + Some( + ClientShellOverlay::Onboarding + | ClientShellOverlay::ProductAnnouncement(_) + | ClientShellOverlay::ReleaseNotes(_) + ) + ) { + self.reconcile_input_source(); + continue; + } + if self.prepare_committed_text(&text, &mut outcome) { + self.reconcile_input_source(); + continue; + } + if let Some(target) = self.popup_input_target() { + super::push_target_event( + target, + ClientPaneInputEvent::Paste(text), + &mut outcome, + ); + } else if !self.popup_pending { + if self.insert_overlay_text(&text) { + outcome.repaint = true; + } else if self.overlay.is_none() && self.mode == ClientShellMode::Terminal { + self.push_focused_pane_event( + ClientPaneInputEvent::Paste(text), + &mut outcome, + ); + } + } + } + RawInputEvent::Mouse(mouse) => self.handle_mouse(mouse, &mut outcome), + RawInputEvent::OuterFocusGained => { + self.outer_focused = Some(true); + outcome.query_host_appearance = true; + outcome.repaint |= self.config.redraw_on_focus_gained; + outcome + .requests + .push(ClientMessage::ClientShellFocus { focused: true }); + } + RawInputEvent::OuterFocusLost => { + self.outer_focused = Some(false); + self.release_input_leases(&mut outcome); + outcome + .requests + .push(ClientMessage::ClientShellFocus { focused: false }); + } + RawInputEvent::HostColorSchemeChanged(appearance) => { + self.host_appearance = Some(appearance); + self.host_appearance_explicit = true; + outcome.query_host_theme = true; + if self.config.theme_runtime.auto_switch { + self.config.palette = crate::app::client_palette_for_appearance( + &self.config.theme_runtime, + appearance, + ); + outcome.repaint = true; + } + } + RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Background, + color, + } if !self.host_appearance_explicit => { + let appearance = color.inferred_appearance(); + self.host_appearance = Some(appearance); + if self.config.theme_runtime.auto_switch { + self.config.palette = crate::app::client_palette_for_appearance( + &self.config.theme_runtime, + appearance, + ); + outcome.repaint = true; + } + } + RawInputEvent::HostDefaultColor { .. } + | RawInputEvent::HostPaletteColors { .. } + | RawInputEvent::HostCellSizeReport { .. } + | RawInputEvent::Unsupported => {} + } + self.reconcile_input_source(); + } + outcome.repaint |= self.resume_mobile_switcher_if_ready(); + outcome + } + + pub(super) fn handle_key( + &mut self, + key: crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + if self.copy_operation_in_flight { + self.copy_input_queue.push_back(key); + return; + } + let lease_key = crate::input::InputLeaseKey::new(LOCAL_INPUT_SOURCE, &key); + let key = self.input_leases.normalize_press(&lease_key, key); + match key.kind { + KeyEventKind::Press => { + let initial_context = self.input_context(); + let target = self.route_key_press(&key, outcome); + if let Some(target) = target.as_ref() { + self.push_pane_key(target.clone(), key.clone(), outcome); + } + let resulting_context = self.input_context(); + let plan = self.input_leases.complete_press( + lease_key, + &key, + Some(&initial_context), + Some(&resulting_context), + target, + ); + self.execute_repeat_plan(lease_key, key, plan, outcome); + } + KeyEventKind::Repeat => { + let context = self.input_context(); + let plan = self + .input_leases + .plan_repeat(lease_key, &key, Some(&context)); + self.execute_repeat_plan(lease_key, key, plan, outcome); + } + KeyEventKind::Release => { + if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { + let release = lease + .key + .with_modifiers(key.modifiers) + .with_kind(KeyEventKind::Release); + self.push_pane_key(lease.target, release, outcome); + } else { + let _ = self.input_leases.remove(&lease_key); + } + } + } + } + + fn release_input_leases(&mut self, outcome: &mut ClientShellInput) { + for lease in self.input_leases.remove_source(LOCAL_INPUT_SOURCE) { + self.push_pane_key( + lease.target, + lease.key.with_kind(KeyEventKind::Release), + outcome, + ); + } + if let Some(gesture) = self.pane_mouse_gesture.take() { + let modifiers = gesture + .last_event + .modifiers + .difference(gesture.stripped_modifiers); + let geometry = matches!( + gesture.last_position, + crate::protocol::ClientMousePosition::Pixels { .. } + ) + .then_some(crate::protocol::ClientMouseGeometry { + cols: gesture.hit.inner_rect.width, + rows: gesture.hit.inner_rect.height, + width_px: gesture.hit.pixel_width, + height_px: gesture.hit.pixel_height, + }); + let target = if gesture.hit.popup { + ClientInputTarget::Popup(gesture.hit.pane_id) + } else { + ClientInputTarget::Pane(gesture.hit.pane_id) + }; + super::push_target_event( + target, + ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::from_crossterm(gesture.button), + ), + position: gesture.last_position, + geometry, + modifiers: modifiers.bits(), + lines: self.config.mouse_scroll_lines.min(u16::MAX as usize) as u16, + }, + outcome, + ); + } + self.copy_input_queue.clear(); + } + + fn execute_repeat_plan( + &mut self, + lease_key: crate::input::InputLeaseKey<u8>, + key: crate::input::TerminalKey, + plan: crate::input::RepeatPlan<ClientInputContext, ClientInputTarget>, + outcome: &mut ClientShellInput, + ) { + match plan { + crate::input::RepeatPlan::Forwarded(target) => { + let pane_blocked_by_popup = matches!(&target, ClientInputTarget::Pane(_)) + && (self.popup_pending || self.popup_terminal_id.is_some()); + if !pane_blocked_by_popup { + self.push_pane_key(target, key, outcome); + } + } + crate::input::RepeatPlan::Reprocess { + context, + repetitions, + tracked, + } => { + for _ in 0..repetitions { + let current = self.input_context(); + if !self.input_leases.reprocess_allowed( + lease_key, + &context, + Some(¤t), + tracked, + ) { + break; + } + let repeated = key + .clone() + .with_repeat_count(1) + .with_kind(KeyEventKind::Repeat); + if let Some(target) = self.route_key_press(&repeated, outcome) { + self.push_pane_key(target, repeated, outcome); + } + } + } + crate::input::RepeatPlan::Ignore => {} + } + } + + pub(super) fn modal_paste_target_active(&self) -> bool { + if self.popup_pending || self.popup_input_target().is_some() { + return false; + } + if self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.search_prompt.is_some()) + { + return self.overlay.is_none(); + } + matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::Rename(_)) + | Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { + creating: false, + .. + } + )) + | Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + search_focused: true, + opening: false, + .. + } + )) + | Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + search_focused: true, + .. + })) + | Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: true, + .. + })) + ) + } + + pub(super) fn handle_modal_paste_shortcut_with( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + read_clipboard_text: impl FnOnce() -> Option<String>, + ) -> bool { + if !is_modal_paste_shortcut(key) || !self.modal_paste_target_active() { + return false; + } + if let Some(text) = read_clipboard_text() { + let inserted = self.insert_copy_search_text(&text) || self.insert_overlay_text(&text); + outcome.repaint |= inserted; + } + true + } + + fn route_key_press( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> Option<ClientInputTarget> { + if self.handle_modal_paste_shortcut_with(key, outcome, crate::platform::read_clipboard_text) + { + return None; + } + if matches!( + self.overlay, + Some( + ClientShellOverlay::Onboarding + | ClientShellOverlay::ProductAnnouncement(_) + | ClientShellOverlay::ReleaseNotes(_) + ) + ) { + if key.kind == KeyEventKind::Press { + self.route_overlay_key(key, outcome); + } + return None; + } + if let Some(target) = self.popup_input_target() { + return Some(target); + } + if self.popup_pending { + return None; + } + if self.overlay.is_some() { + self.route_overlay_key(key, outcome); + return None; + } + if matches!(key.code, KeyCode::Modifier(_)) { + return None; + } + self.pending_word_selection = None; + if self.mode != ClientShellMode::Copy + && self.copy_or_terminal_mode() != ClientShellMode::Copy + && !self.config.copy_on_select + && is_retained_selection_copy_key(key) + && self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible) + { + self.request_selection_copy_with_fallback(outcome, Some(key.clone())); + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + outcome.repaint = true; + return None; + } + if self.mode != ClientShellMode::Copy + && self.copy_or_terminal_mode() != ClientShellMode::Copy + && self.selection.take().is_some() + { + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + outcome.repaint = true; + } + + match self.mode { + ClientShellMode::Terminal => { + if let Some(binding) = + crate::input::resolve_direct_binding(&self.config.keybinds.keybinds, key) + { + self.record_binding(binding, outcome); + return None; + } + if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + self.mode = ClientShellMode::Prefix; + outcome.repaint = true; + return None; + } + self.focused_pane_id().map(ClientInputTarget::Pane) + } + ClientShellMode::Prefix => { + let return_mode = if self.copy_mode.as_ref().is_some_and(|copy_mode| { + self.focused_pane_id().as_deref() == Some(copy_mode.pane_id.as_str()) + }) { + ClientShellMode::Copy + } else { + ClientShellMode::Terminal + }; + if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + self.mode = return_mode; + outcome.repaint = true; + return self.focused_pane_id().map(ClientInputTarget::Pane); + } + if key.code == KeyCode::Esc { + self.mode = return_mode; + outcome.repaint = true; + return None; + } + if let Some(binding) = + crate::input::resolve_prefix_binding(&self.config.keybinds.keybinds, key) + { + self.mode = return_mode; + outcome.repaint = true; + self.record_binding(binding, outcome); + return None; + } + self.mode = return_mode; + outcome.repaint = true; + None + } + ClientShellMode::Navigate => { + self.route_navigate_key(key, outcome); + None + } + ClientShellMode::Resize => { + self.route_resize_key(key, outcome); + None + } + ClientShellMode::Copy => { + if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + self.mode = ClientShellMode::Prefix; + outcome.repaint = true; + } else { + self.route_copy_mode_key(key, outcome); + } + None + } + } + } + + pub(super) fn copy_or_terminal_mode(&self) -> ClientShellMode { + if self.copy_mode.as_ref().is_some_and(|copy_mode| { + self.focused_pane_id().as_deref() == Some(copy_mode.pane_id.as_str()) + }) { + ClientShellMode::Copy + } else { + ClientShellMode::Terminal + } + } + + fn route_navigate_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + use crate::input::{KeybindAction, KeybindDispatch, KeybindMatch}; + + if key.code == KeyCode::Esc + || crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) + { + self.mode = self.copy_or_terminal_mode(); + self.navigate_workspace_id = None; + outcome.repaint = true; + return; + } + + if self + .config + .keybinds + .keybinds + .navigate + .workspace_up + .matches_direct_key(key) + { + self.move_navigate_workspace(-1); + outcome.repaint = true; + return; + } + if self + .config + .keybinds + .keybinds + .navigate + .workspace_down + .matches_direct_key(key) + { + self.move_navigate_workspace(1); + outcome.repaint = true; + return; + } + + if let Some(index) = ('1'..='9').position(|digit| { + crate::config::terminal_key_matches_combo( + key, + (KeyCode::Char(digit), KeyModifiers::empty()), + ) + }) { + let valid = self.snapshot.as_deref().is_some_and(|snapshot| { + self.navigation_workspace_entries(snapshot) + .get(index) + .is_some() + }); + if valid { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + self.record_binding( + KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)), + outcome, + ); + outcome.repaint = true; + } + return; + } + + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + if modifiers.is_empty() { + match code { + KeyCode::Enter => { + let selected = self.navigate_workspace_id.clone(); + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + if let Some(workspace_id) = selected { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { workspace_id }, + ), + outcome, + ); + } + outcome.repaint = true; + return; + } + KeyCode::Tab => { + self.record_navigate_binding( + KeybindMatch::Action(KeybindAction::CyclePaneNext), + false, + outcome, + ); + return; + } + KeyCode::BackTab => { + self.record_navigate_binding( + KeybindMatch::Action(KeybindAction::CyclePanePrevious), + false, + outcome, + ); + return; + } + KeyCode::Left => { + self.record_navigate_binding( + KeybindMatch::Action(KeybindAction::FocusPaneLeft), + true, + outcome, + ); + return; + } + KeyCode::Right => { + self.record_navigate_binding( + KeybindMatch::Action(KeybindAction::FocusPaneRight), + true, + outcome, + ); + return; + } + _ => {} + } + } + + let pane_action = [ + ( + &self.config.keybinds.keybinds.navigate.pane_left, + KeybindAction::FocusPaneLeft, + ), + ( + &self.config.keybinds.keybinds.navigate.pane_down, + KeybindAction::FocusPaneDown, + ), + ( + &self.config.keybinds.keybinds.navigate.pane_up, + KeybindAction::FocusPaneUp, + ), + ( + &self.config.keybinds.keybinds.navigate.pane_right, + KeybindAction::FocusPaneRight, + ), + ] + .into_iter() + .find_map(|(bindings, action)| bindings.matches_direct_key(key).then_some(action)); + if let Some(action) = pane_action { + self.record_navigate_binding(KeybindMatch::Action(action), true, outcome); + return; + } + + let binding = crate::input::resolve_non_indexed_action( + &self.config.keybinds.keybinds, + key, + KeybindDispatch::Prefix, + ) + .filter(|action| { + !matches!( + action, + KeybindAction::FocusPaneLeft + | KeybindAction::FocusPaneDown + | KeybindAction::FocusPaneUp + | KeybindAction::FocusPaneRight + ) + }) + .map(KeybindMatch::Action) + .or_else(|| { + crate::input::resolve_custom_command( + &self.config.keybinds.keybinds, + key, + KeybindDispatch::Prefix, + ) + .map(KeybindMatch::Command) + }) + .or_else(|| { + crate::input::resolve_indexed_action( + &self.config.keybinds.keybinds, + key, + KeybindDispatch::Prefix, + ) + .map(KeybindMatch::Action) + }); + if let Some(binding) = binding { + self.record_navigate_binding(binding, false, outcome); + } + } + + fn record_navigate_binding( + &mut self, + binding: crate::input::KeybindMatch, + preserve_navigate: bool, + outcome: &mut ClientShellInput, + ) { + use crate::input::{KeybindAction, KeybindMatch}; + + let indexed_target_exists = match &binding { + KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)) => { + self.snapshot.as_deref().is_some_and(|snapshot| { + self.navigation_workspace_entries(snapshot) + .get(*index) + .is_some() + }) + } + KeybindMatch::Action(KeybindAction::SwitchTab(index)) => self + .snapshot + .as_deref() + .and_then(|snapshot| { + let workspace_id = snapshot.focused_workspace_id.as_deref()?; + snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .nth(*index) + }) + .is_some(), + KeybindMatch::Action(KeybindAction::FocusAgent(index)) => { + self.snapshot.as_deref().is_some_and(|snapshot| { + super::agent_sidebar::ordered_agent_pane_ids( + snapshot, + self.config.agent_panel_sort, + ) + .get(*index) + .is_some() + }) + } + _ => true, + }; + if !indexed_target_exists { + return; + } + + if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding { + self.cycle_pane(false, outcome); + } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding { + self.cycle_pane(true, outcome); + } else { + if !preserve_navigate { + self.mode = ClientShellMode::Terminal; + } + self.record_binding(binding, outcome); + } + if !preserve_navigate { + if self.mode == ClientShellMode::Navigate { + self.mode = self.copy_or_terminal_mode(); + } + self.navigate_workspace_id = None; + } + outcome.repaint = true; + } + + fn move_navigate_workspace(&mut self, delta: isize) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let mobile = self.mobile_layout_active(); + let entries = self.navigation_workspace_entries(snapshot); + if entries.is_empty() { + return; + } + let current = self + .navigate_workspace_id + .as_deref() + .and_then(|selected| { + entries + .iter() + .position(|entry| snapshot.workspaces[entry.index].workspace_id == selected) + }) + .unwrap_or(0); + let next = if mobile { + (current as isize + delta).clamp(0, entries.len().saturating_sub(1) as isize) as usize + } else { + (current as isize + delta).rem_euclid(entries.len() as isize) as usize + }; + let workspace_id = snapshot.workspaces[entries[next].index] + .workspace_id + .clone(); + self.navigate_workspace_id = Some(workspace_id.clone()); + self.reveal_mobile_workspace = mobile; + if !mobile { + self.reveal_workspace(&workspace_id); + } + } + + fn cycle_pane(&mut self, reverse: bool, outcome: &mut ClientShellInput) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(surface) = self.pane_surface.as_ref() else { + return; + }; + if surface.panes.is_empty() { + return; + } + let current = snapshot + .focused_pane_id + .as_deref() + .and_then(|focused| { + surface + .panes + .iter() + .position(|pane| pane.pane_id == focused) + }) + .unwrap_or(0); + let next = if reverse { + (current + surface.panes.len() - 1) % surface.panes.len() + } else { + (current + 1) % surface.panes.len() + }; + let pane_id = surface.panes[next].pane_id.clone(); + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } + + fn route_resize_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + let resize_bindings = &self.config.keybinds.keybinds.resize_mode; + if key.code == KeyCode::Esc + || key.code == KeyCode::Enter + || resize_bindings.matches_prefix_key(key) + || resize_bindings.matches_direct_key(key) + { + self.mode = self.copy_or_terminal_mode(); + outcome.repaint = true; + return; + } + + let action = match key.code { + KeyCode::Char('h') | KeyCode::Left => Some(crate::input::KeybindAction::ResizePaneLeft), + KeyCode::Char('j') | KeyCode::Down => Some(crate::input::KeybindAction::ResizePaneDown), + KeyCode::Char('k') | KeyCode::Up => Some(crate::input::KeybindAction::ResizePaneUp), + KeyCode::Char('l') | KeyCode::Right => { + Some(crate::input::KeybindAction::ResizePaneRight) + } + _ => None, + }; + if let Some(action) = action { + self.record_binding(crate::input::KeybindMatch::Action(action), outcome); + } + } + + fn input_context(&self) -> ClientInputContext { + ClientInputContext { + mode: self.mode, + overlay: self.overlay.as_ref().map(ClientShellOverlay::kind), + popup_terminal_id: self.popup_input_target().and_then(|target| match target { + ClientInputTarget::Popup(terminal_id) => Some(terminal_id), + ClientInputTarget::Pane(_) => None, + }), + popup_pending: self.popup_pending, + retained_selection: self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible), + } + } + + pub(super) fn focused_pane_id(&self) -> Option<String> { + self.snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_pane_id.clone()) + } + + pub(crate) fn clipboard_image_target( + &self, + ) -> Option<crate::protocol::ClientClipboardImageTarget> { + if matches!( + self.overlay, + Some( + ClientShellOverlay::Onboarding + | ClientShellOverlay::ProductAnnouncement(_) + | ClientShellOverlay::ReleaseNotes(_) + ) + ) || self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.search_prompt.is_some()) + { + return None; + } + if let Some(terminal_id) = self.popup_input_target().and_then(|target| match target { + ClientInputTarget::Popup(terminal_id) => Some(terminal_id), + ClientInputTarget::Pane(_) => None, + }) { + return Some(crate::protocol::ClientClipboardImageTarget::Popup( + terminal_id, + )); + } + if self.popup_pending || self.overlay.is_some() || self.mode != ClientShellMode::Terminal { + return None; + } + self.focused_pane_id() + .map(crate::protocol::ClientClipboardImageTarget::Pane) + } + + fn popup_input_target(&self) -> Option<ClientInputTarget> { + self.popup_terminal_id + .as_ref() + .map(|terminal_id| ClientInputTarget::Popup(terminal_id.clone())) + } + + fn push_pane_key( + &self, + target: ClientInputTarget, + key: crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + if let Some(event) = ClientPaneInputEvent::from_terminal_key(key) { + super::push_target_event(target, event, outcome); + } + } + + fn push_focused_pane_event(&self, event: ClientPaneInputEvent, outcome: &mut ClientShellInput) { + if let Some(pane_id) = self.focused_pane_id() { + super::push_target_event(ClientInputTarget::Pane(pane_id), event, outcome); + } + } +} diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs new file mode 100644 index 00000000..014f51a0 --- /dev/null +++ b/src/client/shell/mobile.rs @@ -0,0 +1,999 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph, Widget}, +}; + +use super::render::{display_width, put_segment, put_text}; +use super::*; + +const MOBILE_BUTTON_WIDTH: u16 = 10; + +struct MobileItem { + lines: Vec<Line<'static>>, + background: Color, + target: Option<ClientMobileTarget>, +} + +impl MobileItem { + fn section(label: impl Into<String>, palette: &Palette) -> Self { + Self { + lines: vec![Line::from(Span::styled( + format!(" {} ", label.into()), + Style::default() + .fg(palette.overlay1) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ))], + background: palette.panel_bg, + target: None, + } + } + + fn action(label: &'static str, target: ClientMobileTarget, palette: &Palette) -> Self { + Self { + lines: vec![Line::from(Span::styled( + label, + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ))], + background: palette.panel_bg, + target: Some(target), + } + } +} + +pub(super) fn render_mobile_header( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + hits: &mut ShellHitMap, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + let button_width = MOBILE_BUTTON_WIDTH.min(area.width); + let button = Rect::new( + area.right().saturating_sub(button_width), + area.y, + button_width, + area.height, + ); + hits.mobile_switch = button; + let status_width = button.x.saturating_sub(area.x).saturating_sub(1); + let status = Rect::new(area.x, area.y, status_width, area.height); + render_header_status(buffer, status, snapshot, config); + render_header_button(buffer, button, snapshot, config); +} + +fn render_header_status( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + let Some(workspace) = snapshot.focused_workspace_id.as_deref().and_then(|id| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == id) + }) else { + put_text( + buffer, + area.x, + area.y, + area.width, + " no workspace", + Style::default().fg(palette.text).bg(palette.panel_bg), + ); + return; + }; + let tab_status = compact_tab_status(snapshot, workspace); + let tab_width = display_width(&tab_status).saturating_add(1).min(area.width); + let name_width = area.width.saturating_sub(tab_width); + put_text( + buffer, + area.x, + area.y, + name_width.min(3), + &format!( + " {} ", + status_icon(workspace.agent_status, config.status_indicators) + ), + Style::default() + .fg(status_color(workspace.agent_status, palette)) + .bg(palette.panel_bg), + ); + put_text( + buffer, + area.x.saturating_add(3), + area.y, + name_width.saturating_sub(3), + &crate::ui::truncate_end(&workspace.label, usize::from(name_width.saturating_sub(4))), + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + area.right().saturating_sub(tab_width).saturating_add(1), + area.y, + tab_width.saturating_sub(1), + &tab_status, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + if area.height > 1 { + render_agent_summary( + buffer, + Rect::new(area.x, area.y + 1, area.width, 1), + snapshot, + config, + ); + } +} + +fn render_header_button( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + buffer.set_style(area, Style::default().bg(palette.surface0)); + for y in area.y..area.bottom() { + put_text( + buffer, + area.x, + y, + 1, + "│", + Style::default() + .fg(palette.surface_dim) + .bg(palette.surface0), + ); + } + let label_y = if area.height > 1 { area.y + 1 } else { area.y }; + let label = "switch"; + let label_width = display_width(label); + put_text( + buffer, + area.x + .saturating_add(1) + .saturating_add(area.width.saturating_sub(1 + label_width) / 2), + label_y, + area.width.saturating_sub(1), + label, + Style::default() + .fg(palette.text) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + if snapshot + .agents + .iter() + .any(|agent| agent.agent_status == crate::api::schema::AgentStatus::Blocked) + { + put_text( + buffer, + area.right().saturating_sub(1), + area.y, + 1, + status_icon( + crate::api::schema::AgentStatus::Blocked, + config.status_indicators, + ), + Style::default().fg(palette.red).bg(palette.surface0), + ); + } +} + +fn compact_tab_status(snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace) -> String { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace.workspace_id) + .collect::<Vec<_>>(); + let active = tabs + .iter() + .position(|tab| tab.tab_id == workspace.active_tab_id) + .unwrap_or(0); + let label = tabs + .get(active) + .map(|tab| tab.label.as_str()) + .unwrap_or("1"); + if tabs.len() <= 1 { + format!("tab {label}") + } else { + format!("tab {label} · {}/{}", active + 1, tabs.len()) + } +} + +fn render_agent_summary( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, +) { + use crate::api::schema::AgentStatus; + let counts = [ + (AgentStatus::Blocked, "blocked"), + (AgentStatus::Done, "done"), + (AgentStatus::Working, "working"), + (AgentStatus::Idle, "idle"), + ] + .map(|(status, label)| { + ( + status, + label, + snapshot + .agents + .iter() + .filter(|agent| agent.agent_status == status) + .count(), + ) + }); + let total = counts.iter().map(|(_, _, count)| count).sum::<usize>(); + let pending = counts[..3].iter().map(|(_, _, count)| count).sum::<usize>(); + if total == 0 { + put_text( + buffer, + area.x, + area.y, + area.width, + " no agents", + Style::default() + .fg(config.palette.overlay1) + .bg(config.palette.panel_bg), + ); + return; + } + if pending == 0 { + put_text( + buffer, + area.x, + area.y, + area.width, + " all idle", + Style::default() + .fg(config.palette.overlay1) + .bg(config.palette.panel_bg), + ); + return; + } + + let mut x = area.x.saturating_add(1); + let mut shown = 0usize; + let mut omitted = false; + for (status, label, count) in counts { + if count == 0 { + continue; + } + let symbol = match (config.status_indicators, status) { + (crate::config::StatusIndicatorStyle::Dots, AgentStatus::Blocked) => Some("◉"), + (crate::config::StatusIndicatorStyle::Dots, AgentStatus::Done) => Some("●"), + (crate::config::StatusIndicatorStyle::Dots, _) => None, + _ => Some(status_icon(status, config.status_indicators)), + }; + let text = symbol.map_or_else( + || format!("{count} {label}"), + |symbol| format!("{symbol} {count} {label}"), + ); + let separator = if shown == 0 { "" } else { " · " }; + let needed = display_width(separator).saturating_add(display_width(&text)); + if area.right().saturating_sub(x) < needed { + omitted = true; + break; + } + if !separator.is_empty() { + x = put_segment( + buffer, + x, + area.y, + area.right(), + separator, + Style::default() + .fg(config.palette.overlay0) + .bg(config.palette.panel_bg), + ); + } + let color = if shown == 0 { + match status { + AgentStatus::Done => config.palette.blue, + _ => status_color(status, &config.palette), + } + } else { + config.palette.overlay1 + }; + x = put_segment( + buffer, + x, + area.y, + area.right(), + &text, + Style::default() + .fg(color) + .bg(config.palette.panel_bg) + .add_modifier(if shown == 0 { + Modifier::BOLD + } else { + Modifier::empty() + }), + ); + shown += 1; + } + if omitted && area.right().saturating_sub(x) >= 2 { + put_text( + buffer, + x, + area.y, + 2, + " …", + Style::default() + .fg(config.palette.overlay0) + .bg(config.palette.panel_bg), + ); + } +} + +pub(super) fn render_mobile_switcher( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + selected_workspace_id: Option<&str>, + scroll: &mut usize, + reveal_workspace: &mut bool, + hits: &mut ShellHitMap, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + Clear.render(area, buffer); + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + hits.mobile_switch = Rect::default(); + if area.height <= 2 { + *scroll = 0; + put_text( + buffer, + area.x, + area.y, + area.width, + &"─".repeat(usize::from(area.width)), + Style::default() + .fg(palette.surface_dim) + .bg(palette.panel_bg), + ); + return; + } + let header_height = area.height.min(2); + let close_width = MOBILE_BUTTON_WIDTH.min(area.width); + let close = Rect::new( + area.right().saturating_sub(close_width), + area.y, + close_width, + header_height, + ); + hits.mobile_close = close; + put_text( + buffer, + area.x, + area.y, + close.x.saturating_sub(area.x), + " switch", + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + render_close_button(buffer, close, palette); + let rule_y = area.y + header_height; + put_text( + buffer, + area.x, + rule_y, + area.width, + &"─".repeat(usize::from(area.width)), + Style::default() + .fg(palette.surface_dim) + .bg(palette.panel_bg), + ); + let viewport = Rect::new( + area.x, + rule_y.saturating_add(1), + area.width, + area.height.saturating_sub(header_height + 1), + ); + if viewport.is_empty() { + *scroll = 0; + return; + } + + let items = mobile_items( + snapshot, + config, + selected_workspace_id, + viewport.width.saturating_sub(1), + ); + let total_rows = items.iter().map(|item| item.lines.len()).sum::<usize>(); + let max_scroll = total_rows.saturating_sub(usize::from(viewport.height)); + *scroll = (*scroll).min(max_scroll); + if *reveal_workspace { + if let Some(selected_workspace_id) = selected_workspace_id { + let mut start = 0usize; + for item in &items { + let end = start.saturating_add(item.lines.len()); + if matches!( + item.target.as_ref(), + Some(ClientMobileTarget::Workspace(workspace_id)) + if workspace_id == selected_workspace_id + ) { + if start < *scroll { + *scroll = start; + } else if end > (*scroll).saturating_add(usize::from(viewport.height)) { + *scroll = end + .saturating_sub(usize::from(viewport.height)) + .min(max_scroll); + } + break; + } + start = end; + } + } + *reveal_workspace = false; + } + hits.mobile_max_scroll = max_scroll; + let content = if viewport.width > 1 { + Rect::new( + viewport.x + 1, + viewport.y, + viewport.width - 1, + viewport.height, + ) + } else { + Rect::default() + }; + if max_scroll > 0 { + render_left_scrollbar(buffer, viewport, total_rows, *scroll, palette); + } + if content.is_empty() { + return; + } + + let viewport_start = *scroll; + let viewport_end = viewport_start.saturating_add(usize::from(viewport.height)); + let mut document_row = 0usize; + for item in items { + let item_start = document_row; + let item_end = item_start.saturating_add(item.lines.len()); + let visible_start = item_start.max(viewport_start); + let visible_end = item_end.min(viewport_end); + if visible_start < visible_end { + let y = viewport.y + u16::try_from(visible_start - viewport_start).unwrap_or(u16::MAX); + let height = u16::try_from(visible_end - visible_start).unwrap_or(u16::MAX); + let rect = Rect::new(content.x, y, content.width, height); + buffer.set_style(rect, Style::default().bg(item.background)); + for row in visible_start..visible_end { + let line = item.lines[row - item_start].clone(); + Paragraph::new(line).render( + Rect::new( + content.x, + viewport.y + u16::try_from(row - viewport_start).unwrap_or(u16::MAX), + content.width, + 1, + ), + buffer, + ); + } + if let Some(target) = item.target { + hits.mobile_targets.push((rect, target)); + } + } + document_row = item_end; + } +} + +fn render_close_button(buffer: &mut Buffer, area: Rect, palette: &Palette) { + if area.is_empty() { + return; + } + buffer.set_style(area, Style::default().bg(palette.surface0)); + for y in area.y..area.bottom() { + put_text( + buffer, + area.x, + y, + 1, + "│", + Style::default() + .fg(palette.surface_dim) + .bg(palette.surface0), + ); + } + let label_width = 5; + let label_x = area + .x + .saturating_add(1) + .saturating_add(area.width.saturating_sub(1 + label_width) / 2); + put_text( + buffer, + label_x, + area.y, + area.width.saturating_sub(1), + "close", + Style::default() + .fg(palette.overlay1) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + if area.height > 1 { + put_text( + buffer, + area.x.saturating_add(area.width / 2), + area.y + 1, + 1, + "×", + Style::default() + .fg(palette.text) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + } +} + +fn mobile_items( + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + selected_workspace_id: Option<&str>, + content_width: u16, +) -> Vec<MobileItem> { + let palette = &config.palette; + let mut items = Vec::new(); + let ordered_agents = + super::agent_sidebar::ordered_agent_pane_ids(snapshot, config.agent_panel_sort); + if !ordered_agents.is_empty() || snapshot.agent_view_label.is_some() { + let title = snapshot + .agent_view_label + .as_deref() + .map(|label| format!("agents · {label}")) + .unwrap_or_else(|| "agents".to_owned()); + items.push(MobileItem::section(title, palette)); + if ordered_agents.is_empty() { + items.push(MobileItem { + lines: vec![Line::from(Span::styled( + " no matching agents", + Style::default() + .fg(palette.overlay0) + .bg(palette.panel_bg) + .add_modifier(Modifier::DIM), + ))], + background: palette.panel_bg, + target: None, + }); + } + for pane_id in ordered_agents { + let Some(agent) = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + else { + continue; + }; + let workspace = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == agent.workspace_id); + let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); + let agent_label = agent + .display_agent + .as_deref() + .or(agent.name.as_deref()) + .or(agent.agent.as_deref()) + .unwrap_or("agent"); + let primary = workspace + .map(|workspace| workspace.label.as_str()) + .unwrap_or(agent_label); + let mut detail = Vec::new(); + let workspace_tab_count = snapshot + .tabs + .iter() + .filter(|candidate| candidate.workspace_id == agent.workspace_id) + .count(); + if let Some(tab) = tab.filter(|tab| tab.custom_label || workspace_tab_count > 1) { + detail.push(tab.label.clone()); + } + let status_key = status_text(agent.agent_status); + detail.push( + agent + .state_labels + .iter() + .find(|(key, _)| key == status_key) + .map(|(_, label)| label.clone()) + .unwrap_or_else(|| { + if agent.agent_status == crate::api::schema::AgentStatus::Unknown { + "idle".to_owned() + } else { + status_key.to_owned() + } + }), + ); + detail.push(agent_label.to_owned()); + let background = if agent.focused { + palette.surface_dim + } else { + palette.panel_bg + }; + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled(" ", Style::default().bg(background)), + Span::styled( + status_icon(agent.agent_status, config.status_indicators), + Style::default() + .fg(status_color(agent.agent_status, palette)) + .bg(background), + ), + Span::styled(" ", Style::default().bg(background)), + Span::styled( + crate::ui::truncate_end( + primary, + usize::from(content_width.saturating_sub(5)), + ), + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + crate::ui::truncate_end( + &format!(" {}", detail.join(" · ")), + usize::from(content_width), + ), + Style::default().fg(palette.overlay0).bg(background), + )), + ], + background, + target: Some(ClientMobileTarget::Agent(agent.pane_id.clone())), + }); + } + } + + items.push(MobileItem::section("spaces", palette)); + items.push(MobileItem::action( + " + new workspace", + ClientMobileTarget::NewWorkspace, + palette, + )); + for entry in super::render::workspace_entries(snapshot, &HashSet::new()) { + let Some(workspace) = snapshot.workspaces.get(entry.index) else { + continue; + }; + let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); + let background = if selected { + palette.surface0 + } else if workspace.focused { + palette.surface_dim + } else { + palette.panel_bg + }; + let connector = if entry.indented { + if entry.last_child { + "└─ " + } else { + "├─ " + } + } else { + "" + }; + let name = if entry.indented && !workspace.custom_label { + workspace + .branch + .as_deref() + .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) + .unwrap_or(&workspace.label) + } else { + &workspace.label + }; + let branch = workspace.branch.as_deref().unwrap_or("shell"); + let detail_prefix = if entry.indented { + if entry.last_child { + " " + } else { + " │ " + } + } else { + " " + }; + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled( + format!(" {connector}"), + Style::default().fg(palette.overlay0).bg(background), + ), + Span::styled( + status_icon(workspace.agent_status, config.status_indicators), + Style::default() + .fg(status_color(workspace.agent_status, palette)) + .bg(background), + ), + Span::styled(" ", Style::default().bg(background)), + Span::styled( + crate::ui::truncate_end( + name, + usize::from(content_width.saturating_sub(if entry.indented { + 8 + } else { + 5 + })), + ), + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + crate::ui::truncate_end( + &format!( + "{detail_prefix}{branch} · {}", + compact_tab_status(snapshot, workspace) + ), + usize::from(content_width), + ), + Style::default().fg(palette.overlay0).bg(background), + )), + ], + background, + target: Some(ClientMobileTarget::Workspace( + workspace.workspace_id.clone(), + )), + }); + } + + if let Some(workspace_id) = snapshot.focused_workspace_id.as_deref() { + items.push(MobileItem::section("tabs", palette)); + items.push(MobileItem::action( + " + new tab", + ClientMobileTarget::NewTab, + palette, + )); + for (index, tab) in snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .enumerate() + { + let background = if tab.focused { + palette.surface_dim + } else { + palette.panel_bg + }; + let label = if tab.custom_label { + format!("{} · {}", index + 1, tab.label) + } else { + format!("tab {}", tab.label) + }; + let label = format!( + " {}", + crate::ui::truncate_end(&label, usize::from(content_width.saturating_sub(3)),) + ); + items.push(MobileItem { + lines: vec![Line::from(Span::styled( + label, + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ))], + background, + target: Some(ClientMobileTarget::Tab(tab.tab_id.clone())), + }); + } + } + + items.push(MobileItem::section("menu", palette)); + for (index, (label, _)) in super::global_menu::global_menu_items(snapshot) + .into_iter() + .enumerate() + { + items.push(MobileItem { + lines: vec![Line::from(Span::styled( + format!(" {label}"), + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ))], + background: palette.panel_bg, + target: Some(ClientMobileTarget::Menu(index)), + }); + } + items +} + +impl ClientShellState { + pub(super) fn handle_mobile_mouse( + &mut self, + mouse: crossterm::event::MouseEvent, + outcome: &mut ClientShellInput, + ) -> bool { + let mobile = self + .last_composed_size + .is_some_and(|(cols, rows)| !self.layout(cols, rows).mobile_header.is_empty()); + if !mobile || self.overlay.is_some() { + return false; + } + use crossterm::event::{MouseButton, MouseEventKind}; + let point = (mouse.column, mouse.row); + if self.mode != ClientShellMode::Navigate { + if matches!( + self.mode, + ClientShellMode::Terminal | ClientShellMode::Resize + ) && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && super::contains(self.hits.mobile_switch, point) + { + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; + self.mode = ClientShellMode::Navigate; + self.navigate_workspace_id = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()); + outcome.repaint = true; + return true; + } + return false; + } + + match mouse.kind { + MouseEventKind::ScrollUp => { + self.mobile_switcher_scroll = self.mobile_switcher_scroll.saturating_sub(2); + outcome.repaint = true; + return true; + } + MouseEventKind::ScrollDown => { + self.mobile_switcher_scroll = self + .mobile_switcher_scroll + .saturating_add(2) + .min(self.hits.mobile_max_scroll); + outcome.repaint = true; + return true; + } + MouseEventKind::Down(MouseButton::Left) => {} + _ => return true, + } + + if super::contains(self.hits.mobile_close, point) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + outcome.repaint = true; + return true; + } + let target = self + .hits + .mobile_targets + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .map(|(_, target)| target.clone()); + match target { + Some(ClientMobileTarget::NewWorkspace) => { + self.mobile_switcher_suspended = true; + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace), + outcome, + ); + } + Some( + target @ (ClientMobileTarget::Workspace(_) + | ClientMobileTarget::Tab(_) + | ClientMobileTarget::Agent(_)), + ) => { + let method = match target { + ClientMobileTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { workspace_id }, + ) + } + ClientMobileTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { + tab_id, + }) + } + ClientMobileTarget::Agent(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }) + } + ClientMobileTarget::NewWorkspace + | ClientMobileTarget::NewTab + | ClientMobileTarget::Menu(_) => return true, + }; + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + self.push_endpoint_method(method, outcome); + } + Some(ClientMobileTarget::NewTab) => { + self.mobile_switcher_suspended = true; + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab), + outcome, + ); + } + Some(ClientMobileTarget::Menu(index)) => { + let actionable = self.snapshot.as_deref().is_some_and(|snapshot| { + super::global_menu::global_menu_items(snapshot) + .get(index) + .is_some_and(|(_, action)| { + *action != super::global_menu::ClientGlobalMenuAction::WhatsNew + || snapshot.release_notes.is_some() + }) + }); + if actionable { + self.mobile_switcher_suspended = true; + self.activate_global_menu_item(index, outcome); + } + } + None => {} + } + outcome.repaint = true; + true + } +} + +fn render_left_scrollbar( + buffer: &mut Buffer, + viewport: Rect, + total_rows: usize, + scroll: usize, + palette: &Palette, +) { + if viewport.is_empty() { + return; + } + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: total_rows + .saturating_sub(usize::from(viewport.height)) + .saturating_sub(scroll), + max_offset_from_bottom: total_rows.saturating_sub(usize::from(viewport.height)), + viewport_rows: usize::from(viewport.height), + }; + let track = Rect::new(viewport.x, viewport.y, 1, viewport.height); + for y in track.y..track.bottom() { + put_text( + buffer, + track.x, + y, + 1, + "│", + Style::default() + .fg(palette.surface_dim) + .bg(palette.panel_bg), + ); + } + if let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) { + for y in thumb.top..thumb.top.saturating_add(thumb.len) { + put_text( + buffer, + track.x, + y, + 1, + "▌", + Style::default().fg(palette.accent).bg(palette.panel_bg), + ); + } + } +} diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs new file mode 100644 index 00000000..437ec4ed --- /dev/null +++ b/src/client/shell/mouse.rs @@ -0,0 +1,2304 @@ +use super::*; +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + +const SELECTION_AUTOSCROLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(30); + +impl ClientShellState { + fn set_sidebar_width_from_column(&mut self, column: u16, outcome: &mut ClientShellInput) { + let (min, max) = crate::config::validated_sidebar_bounds( + self.config.sidebar_min_width, + self.config.sidebar_max_width, + ) + .unwrap_or((18, 36)); + let width = column.saturating_add(1).clamp(min, max); + if self.sidebar_width != width { + self.sidebar_width = width; + self.sidebar_width_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + } + } + + fn set_sidebar_section_from_row(&mut self, row: u16, outcome: &mut ClientShellInput) { + let divider = self.hits.sidebar_divider; + if divider.height == 0 { + return; + } + let ratio = row.saturating_sub(divider.y) as f32 / divider.height as f32; + let ratio = ratio.clamp(0.1, 0.9); + if (self.sidebar_section_split - ratio).abs() > f32::EPSILON { + self.sidebar_section_split = ratio; + self.sidebar_section_split_manual = true; + outcome.repaint = true; + } + } + + fn pane_scrollbar_offset( + hit: &PaneHit, + row: u16, + grab_row_offset: Option<u16>, + ) -> Option<usize> { + let track = hit.scrollbar_rect?; + let metrics = hit.scroll?; + (metrics.max_offset_from_bottom > 0).then(|| match grab_row_offset { + Some(grab_row_offset) => { + crate::ui::scrollbar_offset_from_drag_row(metrics, track, row, grab_row_offset) + } + None => crate::ui::scrollbar_offset_from_row(metrics, track, row), + }) + } + + pub(super) fn push_pane_scroll_offset( + &mut self, + pane_id: String, + offset_from_bottom: usize, + outcome: &mut ClientShellInput, + ) { + self.pane_scroll_targets + .insert(pane_id.clone(), offset_from_bottom); + if self.pane_scroll_in_flight.contains_key(&pane_id) { + self.pane_scroll_queued.insert(pane_id, offset_from_bottom); + return; + } + self.dispatch_pane_scroll_offset(pane_id, offset_from_bottom, outcome); + } + + fn dispatch_pane_scroll_offset( + &mut self, + pane_id: String, + offset_from_bottom: usize, + outcome: &mut ClientShellInput, + ) { + if self.snapshot.is_none() { + return; + } + self.next_scroll_serial = self.next_scroll_serial.saturating_add(1); + let serial = self.next_scroll_serial; + self.pane_scroll_targets + .insert(pane_id.clone(), offset_from_bottom); + self.pane_scroll_in_flight.insert(pane_id.clone(), serial); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneScroll(crate::api::schema::PaneScrollParams { + pane_id: pane_id.clone(), + offset_from_bottom: offset_from_bottom as u64, + }), + PendingEndpointKind::PaneScroll { pane_id, serial }, + outcome, + ); + } + + pub(super) fn complete_pane_scroll( + &mut self, + pane_id: String, + serial: u64, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + outcome: &mut ClientShellInput, + ) -> bool { + if self.pane_scroll_in_flight.get(&pane_id).copied() != Some(serial) { + return false; + } + self.pane_scroll_in_flight.remove(&pane_id); + let repaint = match result { + Ok(crate::api::schema::ResponseResult::PaneInfo { pane }) + if pane.pane_id == pane_id => + { + if let Some(scroll) = pane.scroll { + if self.pane_scroll_targets.contains_key(&pane_id) { + self.pane_scroll_targets.insert( + pane_id.clone(), + usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX), + ); + } + } + false + } + Ok(_) => { + self.pane_scroll_queued.remove(&pane_id); + self.pane_scroll_targets.remove(&pane_id); + self.endpoint_error = + Some("endpoint returned an unexpected pane-scroll result".to_owned()); + true + } + Err(error) => { + self.pane_scroll_queued.remove(&pane_id); + self.pane_scroll_targets.remove(&pane_id); + self.endpoint_error = Some(error.message); + true + } + }; + if let Some(offset) = self.pane_scroll_queued.remove(&pane_id) { + self.dispatch_pane_scroll_offset(pane_id, offset, outcome); + } + repaint + } + + pub(super) fn stop_selection_autoscroll(&mut self) { + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + } + + fn selection_edge_scroll_lines(distance: u16) -> usize { + usize::from(distance).saturating_mul(3).clamp(3, 15) + } + + fn selection_scroll_metrics(&self, hit: &PaneHit) -> Option<crate::pane::ScrollMetrics> { + let metrics = hit.scroll?; + Some( + self.selection_autoscroll + .as_ref() + .filter(|autoscroll| autoscroll.pane_id == hit.pane_id) + .map_or(metrics, |autoscroll| crate::pane::ScrollMetrics { + offset_from_bottom: autoscroll.offset_from_bottom, + max_offset_from_bottom: autoscroll.max_offset_from_bottom, + viewport_rows: metrics.viewport_rows, + }), + ) + } + + fn update_selection_cursor_with_metrics( + &mut self, + hit: &PaneHit, + column: u16, + row: u16, + metrics: Option<crate::pane::ScrollMetrics>, + ) { + if let Some(selection) = self.selection.as_mut() { + selection.drag(column, row, hit.inner_rect, metrics); + } + } + + fn update_selection_drag( + &mut self, + hit: &PaneHit, + column: u16, + row: u16, + outcome: &mut ClientShellInput, + ) { + let metrics = self.selection_scroll_metrics(hit); + let was_dragging = self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_dragging); + let moved_from_anchor = self.selection.as_ref().is_some_and(|selection| { + let (anchor_row, anchor_col) = selection.anchor_screen_pos(hit.inner_rect, metrics); + anchor_row != row || anchor_col != column + }); + let is_dragging = was_dragging || moved_from_anchor; + self.update_selection_cursor_with_metrics(hit, column, row, metrics); + if is_dragging { + if let Some(selection) = self.selection.as_mut() { + if selection.is_just_click() { + selection.force_dragging(); + } + } + self.last_pane_click = None; + } + if !is_dragging { + self.stop_selection_autoscroll(); + return; + } + + let Some(metrics) = metrics else { + self.stop_selection_autoscroll(); + return; + }; + let top = hit.inner_rect.y; + let bottom = hit.inner_rect.y + hit.inner_rect.height.saturating_sub(1); + let (direction, immediate_lines) = if row < top { + ( + ClientSelectionAutoscrollDirection::Up, + Self::selection_edge_scroll_lines(top - row), + ) + } else if row > bottom { + ( + ClientSelectionAutoscrollDirection::Down, + Self::selection_edge_scroll_lines(row - bottom), + ) + } else if row == top { + (ClientSelectionAutoscrollDirection::Up, 0) + } else if row == bottom { + (ClientSelectionAutoscrollDirection::Down, 0) + } else { + self.stop_selection_autoscroll(); + return; + }; + + let offset_from_bottom = match direction { + ClientSelectionAutoscrollDirection::Up => metrics + .offset_from_bottom + .saturating_add(immediate_lines) + .min(metrics.max_offset_from_bottom), + ClientSelectionAutoscrollDirection::Down => { + metrics.offset_from_bottom.saturating_sub(immediate_lines) + } + }; + if offset_from_bottom != metrics.offset_from_bottom { + let projected = crate::pane::ScrollMetrics { + offset_from_bottom, + ..metrics + }; + self.update_selection_cursor_with_metrics(hit, column, row, Some(projected)); + self.push_pane_scroll_offset(hit.pane_id.clone(), offset_from_bottom, outcome); + } + self.selection_autoscroll = Some(ClientSelectionAutoscroll { + pane_id: hit.pane_id.clone(), + direction, + last_mouse_column: column, + last_mouse_row: row, + inner_rect: hit.inner_rect, + offset_from_bottom, + max_offset_from_bottom: metrics.max_offset_from_bottom, + }); + self.selection_autoscroll_deadline = + Some(std::time::Instant::now() + SELECTION_AUTOSCROLL_INTERVAL); + } + + fn scroll_in_progress_selection( + &mut self, + mouse: MouseEvent, + outcome: &mut ClientShellInput, + ) -> bool { + if !matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) || !self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_in_progress) + { + return false; + } + let Some(hit) = self.selection.as_ref().and_then(|selection| { + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == selection.pane_id) + .cloned() + }) else { + return false; + }; + let Some(metrics) = self.selection_scroll_metrics(&hit) else { + return false; + }; + let offset_from_bottom = match mouse.kind { + MouseEventKind::ScrollUp => metrics + .offset_from_bottom + .saturating_add(self.config.mouse_scroll_lines) + .min(metrics.max_offset_from_bottom), + MouseEventKind::ScrollDown => metrics + .offset_from_bottom + .saturating_sub(self.config.mouse_scroll_lines), + _ => unreachable!(), + }; + if offset_from_bottom != metrics.offset_from_bottom { + let projected = crate::pane::ScrollMetrics { + offset_from_bottom, + ..metrics + }; + self.update_selection_cursor_with_metrics( + &hit, + mouse.column, + mouse.row, + Some(projected), + ); + self.push_pane_scroll_offset(hit.pane_id, offset_from_bottom, outcome); + outcome.repaint = true; + } + true + } + + pub(crate) fn tick_selection_autoscroll( + &mut self, + now: std::time::Instant, + ) -> ClientShellInput { + let mut outcome = ClientShellInput::default(); + if self + .selection_autoscroll_deadline + .is_none_or(|deadline| now < deadline) + { + return outcome; + } + let Some(mut autoscroll) = self.selection_autoscroll.clone() else { + self.selection_autoscroll_deadline = None; + return outcome; + }; + if !self.selection.as_ref().is_some_and(|selection| { + selection.pane_id == autoscroll.pane_id && selection.is_dragging() + }) { + self.stop_selection_autoscroll(); + return outcome; + } + let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| hit.pane_id == autoscroll.pane_id) + .cloned() + else { + self.stop_selection_autoscroll(); + return outcome; + }; + if hit.inner_rect != autoscroll.inner_rect { + self.stop_selection_autoscroll(); + return outcome; + } + let next_offset = match autoscroll.direction { + ClientSelectionAutoscrollDirection::Up => autoscroll + .offset_from_bottom + .saturating_add(1) + .min(autoscroll.max_offset_from_bottom), + ClientSelectionAutoscrollDirection::Down => { + autoscroll.offset_from_bottom.saturating_sub(1) + } + }; + if next_offset == autoscroll.offset_from_bottom { + self.stop_selection_autoscroll(); + return outcome; + } + autoscroll.offset_from_bottom = next_offset; + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: next_offset, + max_offset_from_bottom: autoscroll.max_offset_from_bottom, + viewport_rows: hit.scroll.map_or(0, |metrics| metrics.viewport_rows), + }; + self.update_selection_cursor_with_metrics( + &hit, + autoscroll.last_mouse_column, + autoscroll.last_mouse_row, + Some(metrics), + ); + self.push_pane_scroll_offset(autoscroll.pane_id.clone(), next_offset, &mut outcome); + self.selection_autoscroll = Some(autoscroll); + self.selection_autoscroll_deadline = Some(now + SELECTION_AUTOSCROLL_INTERVAL); + outcome.repaint = true; + outcome + } + + fn pane_split_target_is_current(&self, hit: &PaneSplitHit, tab_id: &str) -> Option<bool> { + let snapshot = self.snapshot.as_deref()?; + let surface = self.pane_surface.as_ref()?; + if snapshot.revision != surface.projection_revision { + return None; + } + Some( + snapshot.focused_tab_id.as_deref() == Some(tab_id) + && pane_surface_topology_signature(surface) == hit.topology_signature, + ) + } + + fn pane_split_ratio(hit: &PaneSplitHit, grab_offset: i32, point: (u16, u16)) -> f32 { + let (pointer, origin, length) = match hit.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => { + (i32::from(point.0), i32::from(hit.area.x), hit.area.width) + } + crate::protocol::PaneSurfaceSplitDirection::Vertical => { + (i32::from(point.1), i32::from(hit.area.y), hit.area.height) + } + }; + ((pointer + grab_offset - origin) as f32 / f32::from(length.max(1))).clamp(0.1, 0.9) + } + + fn tab_drop_index_at(&self, point: (u16, u16)) -> Option<usize> { + let snapshot = self.snapshot.as_deref()?; + let workspace_id = snapshot.focused_workspace_id.as_deref()?; + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .collect::<Vec<_>>(); + let visible = self + .hits + .tabs + .iter() + .filter_map(|(rect, tab_id)| { + tabs.iter() + .position(|tab| tab.tab_id == *tab_id) + .map(|index| (index, *rect)) + }) + .collect::<Vec<_>>(); + let (first_index, first_rect) = *visible.first()?; + let (last_index, last_rect) = *visible.last()?; + let on_tab_row = point.1 == first_rect.y; + if !on_tab_row { + return None; + } + if super::contains(self.hits.tab_scroll_left, point) { + return Some(0); + } + if super::contains(self.hits.tab_scroll_right, point) { + return Some(tabs.len()); + } + let left_edge = if first_index == 0 { + first_rect.x + } else { + self.hits.tab_scroll_left.right() + }; + let right_edge = if last_index + 1 >= tabs.len() { + last_rect.right() + } else { + self.hits.tab_scroll_right.x.saturating_sub(1) + }; + if point.0 <= left_edge { + return Some(first_index); + } + if point.0 >= right_edge { + return Some(last_index + 1); + } + for (index, rect) in visible { + let midpoint = rect.x + rect.width / 2; + if point.0 < midpoint { + return Some(index); + } + if point.0 < rect.right() { + return Some(index + 1); + } + } + Some(last_index + 1) + } + + fn workspace_drop_target_at(&self, point: (u16, u16)) -> Option<(Option<String>, u16)> { + if self.hits.workspace_body.height == 0 + || point.1 < self.hits.workspace_body.y.saturating_sub(1) + || point.1 >= self.hits.new_workspace.y + { + return None; + } + let mut slots = self + .hits + .workspaces + .iter() + .filter(|hit| !hit.indented) + .map(|hit| (Some(hit.workspace_id.clone()), hit.rect.y.saturating_sub(1))) + .collect::<Vec<_>>(); + let snapshot = self.snapshot.as_deref()?; + let entries = render::workspace_entries(snapshot, &self.collapsed_groups); + let last_hit = self.hits.workspaces.last()?; + let last_position = entries.iter().position(|entry| { + snapshot + .workspaces + .get(entry.index) + .is_some_and(|workspace| workspace.workspace_id == last_hit.workspace_id) + })?; + let next = entries.get(last_position + 1); + if !next.is_some_and(|entry| entry.indented) { + let before = next.and_then(|entry| { + snapshot + .workspaces + .get(entry.index) + .map(|workspace| workspace.workspace_id.clone()) + }); + let row = last_hit.rect.bottom(); + if row < self.hits.new_workspace.y { + slots.push((before, row)); + } + } + slots + .into_iter() + .enumerate() + .min_by_key(|(index, (_, row))| (point.1.abs_diff(*row), *index)) + .map(|(_, target)| target) + } + + fn workspace_move_method( + &self, + source_workspace_id: &str, + before_workspace_id: Option<&str>, + ) -> Option<crate::api::schema::Method> { + let snapshot = self.snapshot.as_deref()?; + let source = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == source_workspace_id)?; + if source + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + { + return None; + } + if before_workspace_id == Some(source_workspace_id) { + return None; + } + let roots = snapshot + .workspaces + .iter() + .filter(|workspace| { + !workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }) + .collect::<Vec<_>>(); + let source_position = roots + .iter() + .position(|workspace| workspace.workspace_id == source_workspace_id)?; + let remaining = roots + .iter() + .copied() + .filter(|workspace| workspace.workspace_id != source_workspace_id) + .collect::<Vec<_>>(); + let insert_position = match before_workspace_id { + Some(target) => remaining + .iter() + .position(|workspace| workspace.workspace_id == target)?, + None => remaining.len(), + }; + if insert_position == source_position { + return None; + } + + if let Some(worktree) = source.worktree.as_ref() { + let workspace_ids = std::iter::once(source.workspace_id.clone()) + .chain( + snapshot + .workspaces + .iter() + .filter(|workspace| workspace.workspace_id != source.workspace_id) + .filter(|workspace| { + workspace + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .map(|workspace| workspace.workspace_id.clone()), + ) + .collect(); + Some(crate::api::schema::Method::WorkspaceMoveBlock( + crate::api::schema::WorkspaceMoveBlockParams { + workspace_ids, + before_workspace_id: before_workspace_id.map(str::to_owned), + }, + )) + } else { + let insert_index = before_workspace_id + .and_then(|target| { + snapshot + .workspaces + .iter() + .position(|workspace| workspace.workspace_id == target) + }) + .unwrap_or(snapshot.workspaces.len()); + Some(crate::api::schema::Method::WorkspaceMove( + crate::api::schema::WorkspaceMoveParams { + workspace_id: source.workspace_id.clone(), + insert_index, + }, + )) + } + } + + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) { + let point = (mouse.column, mouse.row); + if matches!(self.overlay, Some(ClientShellOverlay::Onboarding)) { + if mouse.kind == MouseEventKind::Down(MouseButton::Left) + && super::contains(self.hits.overlay_primary, point) + { + self.complete_onboarding(outcome); + } + return; + } + if matches!( + self.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + ) { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) + if super::contains(self.hits.overlay_primary, point) => + { + self.dismiss_product_announcement(outcome); + } + MouseEventKind::Down(MouseButton::Left) + if super::contains(self.hits.product_announcement_scrollbar, point) => + { + if let Some(metrics) = self.hits.product_announcement_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.product_announcement_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::ProductAnnouncementScrollbar { + grab_row_offset, + }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.product_announcement_scrollbar, + mouse.row, + ); + self.set_product_announcement_offset_from_bottom(offset); + outcome.repaint = true; + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let ( + Some(ClientChromeDrag::ProductAnnouncementScrollbar { grab_row_offset }), + Some(metrics), + ) = ( + self.chrome_drag.as_ref(), + self.hits.product_announcement_scroll_metrics, + ) { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.product_announcement_scrollbar, + mouse.row, + *grab_row_offset, + ); + self.set_product_announcement_offset_from_bottom(offset); + outcome.repaint = true; + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.chrome_drag = None; + } + MouseEventKind::ScrollUp => { + self.scroll_product_announcement(-3); + outcome.repaint = true; + } + MouseEventKind::ScrollDown => { + self.scroll_product_announcement(3); + outcome.repaint = true; + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::ReleaseNotes(_))) { + let (close, track, metrics) = self + .current_release_notes_input_geometry() + .map(|(close, track, metrics)| (close, track, Some(metrics))) + .unwrap_or(( + self.hits.overlay_primary, + (!self.hits.release_notes_scrollbar.is_empty()) + .then_some(self.hits.release_notes_scrollbar), + self.hits.release_notes_scroll_metrics, + )); + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) if super::contains(close, point) => { + self.dismiss_release_notes(outcome); + } + MouseEventKind::Down(MouseButton::Left) + if track.is_some_and(|track| super::contains(track, point)) => + { + if let (Some(track), Some(metrics)) = (track, metrics) { + if let Some(grab_row_offset) = + crate::ui::scrollbar_thumb_grab_offset(metrics, track, mouse.row) + { + self.chrome_drag = + Some(ClientChromeDrag::ReleaseNotesScrollbar { grab_row_offset }); + } else { + let offset = + crate::ui::scrollbar_offset_from_row(metrics, track, mouse.row); + self.set_release_notes_offset_from_bottom(offset); + outcome.repaint = true; + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let ( + Some(ClientChromeDrag::ReleaseNotesScrollbar { grab_row_offset }), + Some(track), + Some(metrics), + ) = (self.chrome_drag.as_ref(), track, metrics) + { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + mouse.row, + *grab_row_offset, + ); + self.set_release_notes_offset_from_bottom(offset); + outcome.repaint = true; + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.chrome_drag = None; + } + MouseEventKind::ScrollUp => { + self.scroll_release_notes(-3); + outcome.repaint = true; + } + MouseEventKind::ScrollDown => { + self.scroll_release_notes(3); + outcome.repaint = true; + } + _ => {} + } + return; + } + if self.url_click_consumes_until_up { + match mouse.kind { + MouseEventKind::Drag(MouseButton::Left) => return, + MouseEventKind::Up(MouseButton::Left) => { + self.url_click_consumes_until_up = false; + return; + } + MouseEventKind::Down(MouseButton::Left) => { + self.url_click_consumes_until_up = false; + } + _ => {} + } + } + if !self.replaying_url_click + && matches!( + mouse.kind, + MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) + ) + { + if let Some(fallback_events) = + self.pending_requests + .values_mut() + .find_map(|pending| match &mut pending.kind { + PendingEndpointKind::PaneLinkActivate { + fallback_events, .. + } if !fallback_events + .iter() + .any(|event| event.kind == MouseEventKind::Up(MouseButton::Left)) => + { + Some(fallback_events) + } + _ => None, + }) + { + fallback_events.push(mouse); + return; + } + } + if let Some(gesture) = self.pane_mouse_gesture.as_ref() { + let gesture_event = matches!( + mouse.kind, + MouseEventKind::Drag(button) | MouseEventKind::Up(button) + if button == gesture.button + ); + if gesture_event { + let button = gesture.button; + let modifiers = mouse.modifiers.difference(gesture.stripped_modifiers); + let hit = if gesture.hit.popup { + self.hits + .popup + .as_ref() + .filter(|hit| hit.pane_id == gesture.hit.pane_id) + .cloned() + } else { + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == gesture.hit.pane_id) + .cloned() + } + .unwrap_or_else(|| gesture.hit.clone()); + let position = self.pane_mouse_position(&hit, mouse); + if let Some(gesture) = self.pane_mouse_gesture.as_mut() { + gesture.last_event = mouse; + gesture.last_position = position; + } + self.push_pane_mouse_event(&hit, mouse, modifiers, outcome); + if mouse.kind == MouseEventKind::Up(button) { + self.pane_mouse_gesture = None; + } + return; + } + if matches!( + mouse.kind, + MouseEventKind::Down(_) | MouseEventKind::Drag(_) | MouseEventKind::Up(_) + ) { + return; + } + } + if self.popup_pending { + return; + } + if let Some(hit) = self.hits.popup.clone() { + if super::contains(hit.inner_rect, point) { + match mouse.kind { + MouseEventKind::Down(button) => { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + if hit.mouse_reporting { + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit, + button, + stripped_modifiers: crossterm::event::KeyModifiers::empty(), + last_event: mouse, + }); + } + } + MouseEventKind::Moved if hit.mouse_reporting => { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + MouseEventKind::Up(_) | MouseEventKind::Drag(_) | MouseEventKind::Moved => {} + } + } + return; + } + if self.popup_terminal_id.is_some() { + return; + } + if !self.replaying_url_click + && self.overlay.is_none() + && self.mode == ClientShellMode::Terminal + && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && mouse + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point)) + .cloned() + { + let viewport_row = mouse.row.saturating_sub(hit.inner_rect.y); + let col = mouse.column.saturating_sub(hit.inner_rect.x); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| { + surface + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) + .map(|pane| pane.content_revision); + self.last_pane_click = None; + let pane_id = hit.pane_id.clone(); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneLinkActivate( + crate::api::schema::PaneLinkActivateParams { + pane_id: pane_id.clone(), + viewport_row, + col, + content_revision, + offset_from_bottom: hit + .scroll + .map(|metrics| metrics.offset_from_bottom as u64), + }, + ), + PendingEndpointKind::PaneLinkActivate { + pane_id, + inner_rect: hit.inner_rect, + fallback_events: vec![mouse], + }, + outcome, + ); + return; + } + } + if self.overlay.is_none() + && self.mode == ClientShellMode::Terminal + && self + .visible_notification + .as_ref() + .is_some_and(|notification| notification.event.pane_id.is_some()) + && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && super::contains(self.hits.notification_toast, point) + { + self.focus_visible_notification(outcome); + return; + } + if self.handle_mobile_mouse(mouse, outcome) { + return; + } + if mouse.kind == MouseEventKind::Drag(MouseButton::Left) { + match self.chrome_drag.as_ref() { + Some(ClientChromeDrag::SidebarWidth) => { + self.set_sidebar_width_from_column(mouse.column, outcome); + return; + } + Some(ClientChromeDrag::SidebarSection) => { + self.set_sidebar_section_from_row(mouse.row, outcome); + return; + } + Some(ClientChromeDrag::WorkspaceScrollbar { grab_row_offset }) => { + if let Some(metrics) = self.hits.workspace_scroll_metrics { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.workspace_scrollbar, + mouse.row, + *grab_row_offset, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + return; + } + Some(ClientChromeDrag::AgentScrollbar { grab_row_offset }) => { + if let Some(metrics) = self.hits.agent_scroll_metrics { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.agent_scrollbar, + mouse.row, + *grab_row_offset, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + return; + } + Some(ClientChromeDrag::HelpScrollbar { grab_row_offset }) => { + if let (Some(metrics), Some(ClientShellOverlay::Help(help))) = + (self.hits.help_scroll_metrics, self.overlay.as_mut()) + { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.help_scrollbar, + mouse.row, + *grab_row_offset, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != help.scroll { + help.scroll = next; + outcome.repaint = true; + } + } + return; + } + Some( + ClientChromeDrag::ProductAnnouncementScrollbar { .. } + | ClientChromeDrag::ReleaseNotesScrollbar { .. }, + ) => { + self.chrome_drag = None; + return; + } + Some(ClientChromeDrag::PaneScrollbar { + hit, + grab_row_offset, + last_sent_offset, + last_sent_at, + }) => { + let current_hit = self + .hits + .panes + .iter() + .find(|current| current.pane_id == hit.pane_id) + .cloned() + .unwrap_or_else(|| hit.clone()); + let Some(offset) = Self::pane_scrollbar_offset( + ¤t_hit, + mouse.row, + Some(*grab_row_offset), + ) else { + self.chrome_drag = None; + return; + }; + let now = std::time::Instant::now(); + let should_send = *last_sent_offset != Some(offset) + && last_sent_at.is_none_or(|last| { + now.duration_since(last) >= std::time::Duration::from_millis(33) + }); + if should_send { + if let Some(ClientChromeDrag::PaneScrollbar { + last_sent_offset, + last_sent_at, + .. + }) = self.chrome_drag.as_mut() + { + *last_sent_offset = Some(offset); + *last_sent_at = Some(now); + } + self.push_pane_scroll_offset(current_hit.pane_id, offset, outcome); + } + return; + } + Some(ClientChromeDrag::PaneSplit { + hit, + tab_id, + grab_offset, + last_sent_at, + .. + }) => { + let hit = hit.clone(); + let tab_id = tab_id.clone(); + let grab_offset = *grab_offset; + match self.pane_split_target_is_current(&hit, &tab_id) { + Some(true) => {} + Some(false) => { + self.chrome_drag = None; + return; + } + None => return, + } + let ratio = Self::pane_split_ratio(&hit, grab_offset, point); + let now = std::time::Instant::now(); + let should_send = last_sent_at.is_none_or(|last| { + now.duration_since(last) >= std::time::Duration::from_millis(33) + }); + if let Some(ClientChromeDrag::PaneSplit { + last_sent_ratio, + last_sent_at, + .. + }) = self.chrome_drag.as_mut() + { + if should_send { + *last_sent_ratio = Some(ratio); + *last_sent_at = Some(now); + } + } + if should_send { + self.push_endpoint_method( + crate::api::schema::Method::LayoutSetSplitRatio( + crate::api::schema::LayoutSetSplitRatioParams { + tab_id: Some(tab_id), + pane_id: None, + path: hit.path, + ratio, + }, + ), + outcome, + ); + } + return; + } + Some(ClientChromeDrag::Tab { .. }) => { + let insert_index = self.tab_drop_index_at(point); + if let Some(ClientChromeDrag::Tab { + insert_index: current, + .. + }) = self.chrome_drag.as_mut() + { + *current = insert_index; + } + outcome.repaint = true; + return; + } + Some(ClientChromeDrag::Workspace { .. }) => { + let target = self.workspace_drop_target_at(point); + if let Some(ClientChromeDrag::Workspace { + target: current, .. + }) = self.chrome_drag.as_mut() + { + *current = target; + } + outcome.repaint = true; + return; + } + None => {} + } + if let Some(press) = self.workspace_press.as_ref() { + let delta = mouse + .column + .abs_diff(press.start_column) + .max(mouse.row.abs_diff(press.start_row)); + if delta >= 1 { + let source_workspace_id = press.workspace_id.clone(); + let draggable = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == source_workspace_id) + }) + .is_some_and(|workspace| { + !workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }); + if draggable { + if let Some(target) = self.workspace_drop_target_at(point) { + self.chrome_drag = Some(ClientChromeDrag::Workspace { + source_workspace_id, + target: Some(target), + }); + outcome.repaint = true; + } + } + } + return; + } + if let Some(press) = self.tab_press.as_ref() { + let delta = mouse + .column + .abs_diff(press.start_column) + .max(mouse.row.abs_diff(press.start_row)); + if delta >= 1 { + if let Some(insert_index) = self.tab_drop_index_at(point) { + self.chrome_drag = Some(ClientChromeDrag::Tab { + tab_id: press.tab_id.clone(), + workspace_id: press.workspace_id.clone(), + insert_index: Some(insert_index), + }); + outcome.repaint = true; + } + } + return; + } + } + if mouse.kind == MouseEventKind::Up(MouseButton::Left) { + if let Some(drag) = self.chrome_drag.take() { + self.workspace_press = None; + self.tab_press = None; + match drag { + ClientChromeDrag::Tab { + tab_id, + workspace_id, + .. + } => { + let insert_index = self.tab_drop_index_at(point); + let valid_drop = self.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.focused_workspace_id.as_deref() == Some(workspace_id.as_str()) + && snapshot.tabs.iter().any(|tab| { + tab.tab_id == tab_id && tab.workspace_id == workspace_id + }) + && insert_index.is_some_and(|index| { + index + <= snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .count() + }) + }); + if valid_drop { + self.push_endpoint_method( + crate::api::schema::Method::TabMove( + crate::api::schema::TabMoveParams { + tab_id, + insert_index: insert_index.unwrap_or_default(), + }, + ), + outcome, + ); + } + outcome.repaint = true; + } + ClientChromeDrag::Workspace { + source_workspace_id, + target, + } => { + if let Some((before_workspace_id, _)) = target { + if let Some(method) = self.workspace_move_method( + &source_workspace_id, + before_workspace_id.as_deref(), + ) { + self.push_endpoint_method(method, outcome); + } + } + outcome.repaint = true; + } + ClientChromeDrag::PaneScrollbar { + hit, + grab_row_offset, + last_sent_offset, + .. + } => { + let current_hit = self + .hits + .panes + .iter() + .find(|current| current.pane_id == hit.pane_id) + .cloned() + .unwrap_or(hit); + if let Some(offset) = Self::pane_scrollbar_offset( + ¤t_hit, + mouse.row, + Some(grab_row_offset), + ) { + if last_sent_offset != Some(offset) { + self.push_pane_scroll_offset(current_hit.pane_id, offset, outcome); + } + } + } + ClientChromeDrag::PaneSplit { + hit, + tab_id, + grab_offset, + last_sent_ratio, + .. + } => { + let target_is_current = + self.pane_split_target_is_current(&hit, &tab_id) == Some(true); + let ratio = Self::pane_split_ratio(&hit, grab_offset, point); + if target_is_current + && last_sent_ratio + .is_none_or(|sent| (sent - ratio).abs() > f32::EPSILON) + { + self.push_endpoint_method( + crate::api::schema::Method::LayoutSetSplitRatio( + crate::api::schema::LayoutSetSplitRatioParams { + tab_id: Some(tab_id), + pane_id: None, + path: hit.path, + ratio, + }, + ), + outcome, + ); + } + } + ClientChromeDrag::SidebarWidth | ClientChromeDrag::SidebarSection => { + self.persist_chrome_preferences(outcome); + } + ClientChromeDrag::WorkspaceScrollbar { .. } + | ClientChromeDrag::AgentScrollbar { .. } + | ClientChromeDrag::HelpScrollbar { .. } + | ClientChromeDrag::ProductAnnouncementScrollbar { .. } + | ClientChromeDrag::ReleaseNotesScrollbar { .. } => {} + } + return; + } + if let Some(press) = self.workspace_press.take() { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { + workspace_id: press.workspace_id, + }, + ), + outcome, + ); + return; + } + if let Some(press) = self.tab_press.take() { + self.push_endpoint_method( + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { + tab_id: press.tab_id, + }), + outcome, + ); + return; + } + } + if matches!(self.overlay, Some(ClientShellOverlay::GlobalMenu(_))) { + let row_hit = self + .hits + .global_menu_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied(); + match mouse.kind { + MouseEventKind::Moved => { + if let (Some((_, index)), Some(ClientShellOverlay::GlobalMenu(menu))) = + (row_hit, self.overlay.as_mut()) + { + menu.highlighted = index; + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.global_launcher, point) { + self.toggle_global_menu(); + outcome.repaint = true; + } else if let Some((_, index)) = row_hit { + self.activate_global_menu_item(index, outcome); + } else { + self.overlay = None; + outcome.repaint = true; + } + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::ContextMenu(_))) { + let row_hit = self + .hits + .context_menu_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied(); + match mouse.kind { + MouseEventKind::Moved => { + if let (Some((_, index)), Some(ClientShellOverlay::ContextMenu(menu))) = + (row_hit, self.overlay.as_mut()) + { + menu.highlighted = index; + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some((_, index)) = row_hit { + self.activate_context_menu_item(index, outcome); + } else { + self.overlay = None; + outcome.repaint = true; + } + } + _ => {} + } + return; + } + if matches!( + self.overlay, + Some( + ClientShellOverlay::WorktreeCreate(_) + | ClientShellOverlay::WorktreeOpen(_) + | ClientShellOverlay::WorktreeRemove(_) + ) + ) { + match mouse.kind { + MouseEventKind::ScrollUp + if matches!(self.overlay, Some(ClientShellOverlay::WorktreeOpen(_))) => + { + self.move_worktree_open_selection(-1); + outcome.repaint = true; + } + MouseEventKind::ScrollDown + if matches!(self.overlay, Some(ClientShellOverlay::WorktreeOpen(_))) => + { + self.move_worktree_open_selection(1); + outcome.repaint = true; + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.overlay_cancel, point) { + let busy = + matches!( + self.overlay, + Some( + ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { creating: true, .. } + ) | ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { opening: true, .. } + ) | ClientShellOverlay::WorktreeRemove( + ClientWorktreeRemoveOverlay { removing: true, .. } + ) + ) + ); + if !busy { + self.overlay = None; + outcome.repaint = true; + } + } else if super::contains(self.hits.worktree_search, point) { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.search_focused = true; + outcome.repaint = true; + } + } else if let Some((_, index)) = self + .hits + .worktree_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied() + { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.selected = index; + } + self.submit_worktree_open(outcome); + } else if super::contains(self.hits.overlay_primary, point) { + match self.overlay.as_ref() { + Some(ClientShellOverlay::WorktreeCreate(_)) => { + self.submit_worktree_create(outcome) + } + Some(ClientShellOverlay::WorktreeOpen(_)) => { + self.submit_worktree_open(outcome) + } + Some(ClientShellOverlay::WorktreeRemove(_)) => { + self.submit_worktree_remove(outcome) + } + _ => {} + } + } + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) { + if mouse.kind == MouseEventKind::Down(MouseButton::Left) { + if let Some((_, section)) = self + .hits + .settings_tabs + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied() + { + self.select_settings_section(section, outcome); + } else if let Some((_, index)) = self + .hits + .settings_choices + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied() + { + self.select_settings_choice(index); + let immediate = matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Indicators + | ClientSettingsSection::Sound + | ClientSettingsSection::Toast, + .. + })) + ); + if immediate { + self.apply_settings_choice(outcome); + } + outcome.repaint = true; + } else if super::contains(self.hits.overlay_primary, point) { + self.apply_settings_choice(outcome); + } else if super::contains(self.hits.overlay_cancel, point) + || !super::contains(self.hits.settings_popup, point) + { + let installing = matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + installing_integrations: true, + .. + })) + ); + if !installing { + self.cancel_settings_overlay(); + outcome.repaint = true; + } + } + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Help(_))) { + match mouse.kind { + MouseEventKind::ScrollUp => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + let next = help.scroll.saturating_sub(3); + if next != help.scroll { + help.scroll = next; + outcome.repaint = true; + } + } + } + MouseEventKind::ScrollDown => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + let next = help.scroll.saturating_add(3).min(self.hits.help_max_scroll); + if next != help.scroll { + help.scroll = next; + outcome.repaint = true; + } + } + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.help_scrollbar, point) { + if let Some(metrics) = self.hits.help_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.help_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::HelpScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.help_scrollbar, + mouse.row, + ); + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() + { + help.scroll = + metrics.max_offset_from_bottom.saturating_sub(offset); + outcome.repaint = true; + } + } + } + } else if super::contains(self.hits.overlay_cancel, point) { + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: true, + .. + })) + ); + if search_focused { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.search_focused = false; + help.query.clear(); + help.scroll = 0; + } + } else { + self.overlay = None; + } + outcome.repaint = true; + } else if !super::contains(self.hits.help_popup, point) { + self.overlay = None; + outcome.repaint = true; + } + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Navigator(_))) { + let row_hit = self + .hits + .navigator_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied(); + match mouse.kind { + MouseEventKind::Moved => { + if let Some((_, index)) = row_hit { + if let Some(ClientShellOverlay::Navigator(navigator)) = + self.overlay.as_mut() + { + navigator.selected = index; + } + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.navigator_search, point) { + if let Some(ClientShellOverlay::Navigator(navigator)) = + self.overlay.as_mut() + { + navigator.search_focused = true; + navigator.filter = None; + } + outcome.repaint = true; + } else if let Some((rect, index)) = row_hit { + if let Some(ClientShellOverlay::Navigator(navigator)) = + self.overlay.as_mut() + { + navigator.selected = index; + } + let workspace = self + .snapshot + .as_deref() + .zip(self.overlay.as_ref()) + .and_then(|(snapshot, overlay)| match overlay { + ClientShellOverlay::Navigator(navigator) => { + render::client_navigator_rows(snapshot, navigator) + .get(index) + .map(|row| { + matches!( + row.target, + ClientNavigatorTarget::Workspace(_) + ) + }) + } + _ => None, + }) + .unwrap_or(false); + if workspace && mouse.column <= rect.x.saturating_add(3) { + self.toggle_selected_navigator_workspace(); + outcome.repaint = true; + } else { + self.accept_navigator_selection(outcome); + } + } else if !super::contains(self.hits.navigator_popup, point) { + self.overlay = None; + outcome.repaint = true; + } + } + MouseEventKind::ScrollUp => { + self.move_navigator_selection(-3); + outcome.repaint = true; + } + MouseEventKind::ScrollDown => { + self.move_navigator_selection(3); + outcome.repaint = true; + } + _ => {} + } + return; + } + if self.overlay.is_some() { + if mouse.kind != MouseEventKind::Down(MouseButton::Left) { + return; + } + if super::contains(self.hits.overlay_primary, point) { + match self.overlay.as_ref() { + Some(ClientShellOverlay::Rename(_)) => self.save_rename_overlay(outcome), + Some(ClientShellOverlay::ConfirmClose(_)) => { + let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() + else { + return; + }; + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id: confirm.workspace_id, + close_group: true, + }, + ), + outcome, + ); + outcome.repaint = true; + } + _ => {} + } + } else if super::contains(self.hits.overlay_clear, point) { + if let Some(ClientShellOverlay::Rename(rename)) = self.overlay.as_mut() { + rename.input.clear(); + rename.replace_on_type = false; + outcome.repaint = true; + } + } else { + self.overlay = None; + outcome.repaint = true; + } + return; + } + + if mouse.kind == MouseEventKind::Drag(MouseButton::Left) { + let selection_hit = self.selection.as_ref().and_then(|selection| { + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == selection.pane_id) + .cloned() + }); + if let Some(hit) = selection_hit { + self.update_selection_drag(&hit, mouse.column, mouse.row, outcome); + outcome.repaint = true; + return; + } + } + if mouse.kind == MouseEventKind::Up(MouseButton::Left) && self.selection.is_some() { + self.stop_selection_autoscroll(); + let copied = self + .selection + .as_mut() + .is_some_and(crate::selection::Selection::finish); + if copied && self.config.copy_on_select { + self.request_selection_copy(outcome); + self.selection = None; + } else if !copied { + self.selection = None; + } + if copied { + self.last_pane_click = None; + } + outcome.repaint = true; + return; + } + if self.scroll_in_progress_selection(mouse, outcome) { + return; + } + + match mouse.kind { + MouseEventKind::Down(MouseButton::Right) => { + let pane_hit = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point)) + .cloned(); + if let Some(hit) = pane_hit { + let pane_owns_right_click = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) + .is_some_and(|pane| pane.right_click_passthrough) + && mouse.modifiers.is_empty(); + let configured_modifiers = self + .config + .right_click_passthrough_modifiers + .filter(|modifiers| *modifiers == mouse.modifiers); + if hit.mouse_reporting + && (pane_owns_right_click || configured_modifiers.is_some()) + { + let stripped_modifiers = + configured_modifiers.unwrap_or(crossterm::event::KeyModifiers::empty()); + self.push_pane_mouse_event( + &hit, + mouse, + mouse.modifiers.difference(stripped_modifiers), + outcome, + ); + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id.clone(), + }), + outcome, + ); + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit, + button: MouseButton::Right, + stripped_modifiers, + last_event: mouse, + }); + return; + } + } + if !self.config.mouse_capture { + return; + } + let workspace_id = (!self.sidebar_collapsed) + .then(|| { + self.hits + .workspaces + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| hit.workspace_id.clone()) + }) + .flatten(); + if let Some(workspace_id) = workspace_id { + self.open_workspace_context_menu(workspace_id, mouse.column, mouse.row); + outcome.repaint = true; + return; + } + let tab_id = self + .hits + .tabs + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .map(|(_, tab_id)| tab_id.clone()); + if let Some(tab_id) = tab_id { + self.open_tab_context_menu(tab_id, mouse.column, mouse.row); + outcome.repaint = true; + return; + } + let pane_id = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| hit.pane_id.clone()); + if let Some(pane_id) = pane_id { + self.open_pane_context_menu(pane_id, mouse.column, mouse.row); + outcome.repaint = true; + } + } + MouseEventKind::ScrollUp + if self + .hits + .tabs + .iter() + .any(|(rect, _)| super::contains(*rect, point)) + || super::contains(self.hits.tab_scroll_left, point) + || super::contains(self.hits.tab_scroll_right, point) + || super::contains(self.hits.new_tab, point) => + { + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::PreviousTab), + outcome, + ); + } + MouseEventKind::ScrollDown + if self + .hits + .tabs + .iter() + .any(|(rect, _)| super::contains(*rect, point)) + || super::contains(self.hits.tab_scroll_left, point) + || super::contains(self.hits.tab_scroll_right, point) + || super::contains(self.hits.new_tab, point) => + { + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NextTab), + outcome, + ); + } + MouseEventKind::ScrollUp if super::contains(self.hits.agent_body, point) => { + let next = self.agent_scroll.saturating_sub(1); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::ScrollDown if super::contains(self.hits.agent_body, point) => { + let next = self + .agent_scroll + .saturating_add(1) + .min(self.hits.agent_max_scroll); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::ScrollUp if super::contains(self.hits.workspace_body, point) => { + let next = self.workspace_scroll.saturating_sub(1); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::ScrollDown if super::contains(self.hits.workspace_body, point) => { + let next = self + .workspace_scroll + .saturating_add(1) + .min(self.hits.workspace_max_scroll); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if self.selection.take().is_some() { + outcome.repaint = true; + } + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + let previous_pane_click = self.last_pane_click.take(); + self.workspace_press = None; + self.tab_press = None; + self.chrome_drag = None; + if super::contains(self.hits.sidebar_divider, point) + && !super::contains(self.hits.sidebar_toggle, point) + { + let now = std::time::Instant::now(); + let double_click = self.last_sidebar_divider_click.is_some_and(|last| { + now.duration_since(last) <= std::time::Duration::from_millis(350) + }); + self.last_sidebar_divider_click = Some(now); + if double_click { + self.sidebar_width = self.config.sidebar_width; + self.sidebar_width_manual = false; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + } else { + self.chrome_drag = Some(ClientChromeDrag::SidebarWidth); + self.set_sidebar_width_from_column(mouse.column, outcome); + } + return; + } + if super::contains(self.hits.sidebar_section_divider, point) { + self.chrome_drag = Some(ClientChromeDrag::SidebarSection); + self.set_sidebar_section_from_row(mouse.row, outcome); + return; + } + if super::contains(self.hits.workspace_scrollbar, point) { + if let Some(metrics) = self.hits.workspace_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.workspace_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::WorkspaceScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.workspace_scrollbar, + mouse.row, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + } + return; + } + if super::contains(self.hits.agent_scrollbar, point) { + if let Some(metrics) = self.hits.agent_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.agent_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::AgentScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.agent_scrollbar, + mouse.row, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + } + return; + } + if super::contains(self.hits.agent_sort_toggle, point) { + let sort = match self.config.agent_panel_sort { + crate::config::AgentPanelSortConfig::Spaces => { + crate::config::AgentPanelSortConfig::Priority + } + crate::config::AgentPanelSortConfig::Priority => { + crate::config::AgentPanelSortConfig::Spaces + } + }; + self.config.agent_panel_sort = sort; + self.agent_panel_sort_manual = true; + self.agent_scroll = 0; + self.persist_chrome_preferences(outcome); + outcome.repaint = true; + return; + } + if super::contains(self.hits.global_launcher, point) { + self.toggle_global_menu(); + outcome.repaint = true; + return; + } + if super::contains(self.hits.new_workspace, point) { + self.record_binding( + crate::input::KeybindMatch::Action( + crate::input::KeybindAction::NewWorkspace, + ), + outcome, + ); + return; + } + if super::contains(self.hits.new_tab, point) { + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab), + outcome, + ); + return; + } + if super::contains(self.hits.tab_scroll_left, point) { + self.tab_scroll = self.tab_scroll.saturating_sub(1); + outcome.repaint = true; + return; + } + if super::contains(self.hits.tab_scroll_right, point) { + let tab_count = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot.focused_workspace_id.as_deref().map(|id| { + snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == id) + .count() + }) + }) + .unwrap_or(0); + self.tab_scroll = self + .tab_scroll + .saturating_add(1) + .min(tab_count.saturating_sub(1)); + outcome.repaint = true; + return; + } + if super::contains(self.hits.sidebar_toggle, point) { + self.sidebar_collapsed = !self.sidebar_collapsed; + self.sidebar_collapsed_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + return; + } + for hit in &self.hits.workspaces { + if let Some((rect, key)) = &hit.group_toggle { + if super::contains(*rect, point) { + if !self.collapsed_groups.remove(key) { + self.collapsed_groups.insert(key.clone()); + } + outcome.repaint = true; + self.persist_chrome_preferences(outcome); + return; + } + } + } + let workspace_press = self + .hits + .workspaces + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| ClientWorkspacePress { + workspace_id: hit.workspace_id.clone(), + start_column: mouse.column, + start_row: mouse.row, + }); + if let Some(workspace_press) = workspace_press { + self.workspace_press = Some(workspace_press); + return; + } + let tab_press = self + .config + .mouse_capture + .then(|| { + self.hits + .tabs + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .and_then(|(_, tab_id)| { + let tab = self + .snapshot + .as_deref()? + .tabs + .iter() + .find(|tab| tab.tab_id == *tab_id)?; + Some(ClientTabPress { + tab_id: tab.tab_id.clone(), + workspace_id: tab.workspace_id.clone(), + start_column: mouse.column, + start_row: mouse.row, + }) + }) + }) + .flatten(); + if let Some(tab_press) = tab_press { + self.tab_press = Some(tab_press); + return; + } + let agent_pane_id = self + .hits + .agents + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .map(|(_, pane_id)| pane_id.clone()); + if let Some(pane_id) = agent_pane_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }), + outcome, + ); + return; + } + let scrollbar_hit = self + .hits + .panes + .iter() + .find(|hit| { + hit.scrollbar_rect + .is_some_and(|rect| super::contains(rect, point)) + && hit + .scroll + .is_some_and(|metrics| metrics.max_offset_from_bottom > 0) + }) + .cloned(); + if let Some(hit) = scrollbar_hit { + self.mode = ClientShellMode::Terminal; + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id.clone(), + }), + outcome, + ); + let (Some(track), Some(metrics)) = (hit.scrollbar_rect, hit.scroll) else { + return; + }; + if let Some(grab_row_offset) = + crate::ui::scrollbar_thumb_grab_offset(metrics, track, mouse.row) + { + self.chrome_drag = Some(ClientChromeDrag::PaneScrollbar { + hit, + grab_row_offset, + last_sent_offset: None, + last_sent_at: None, + }); + } else if let Some(offset) = Self::pane_scrollbar_offset(&hit, mouse.row, None) + { + self.push_pane_scroll_offset(hit.pane_id, offset, outcome); + } + return; + } + let split_hit = self + .hits + .pane_splits + .iter() + .find(|hit| super::contains(hit.hit_rect, point)) + .cloned(); + if let Some(hit) = split_hit { + let Some(tab_id) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_tab_id.clone()) + else { + return; + }; + let pointer = match hit.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => mouse.column, + crate::protocol::PaneSurfaceSplitDirection::Vertical => mouse.row, + }; + self.chrome_drag = Some(ClientChromeDrag::PaneSplit { + grab_offset: i32::from(hit.pos) - i32::from(pointer), + last_sent_ratio: None, + last_sent_at: None, + hit, + tab_id, + }); + return; + } + let pane_hit = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.rect, point)) + .cloned(); + if let Some(hit) = pane_hit { + if hit.mouse_reporting && super::contains(hit.inner_rect, point) { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit: hit.clone(), + button: MouseButton::Left, + stripped_modifiers: crossterm::event::KeyModifiers::empty(), + last_event: mouse, + }); + } else if super::contains(hit.inner_rect, point) { + let click = ClientPaneClick { + pane_id: hit.pane_id.clone(), + viewport_row: mouse.row.saturating_sub(hit.inner_rect.y), + col: mouse.column.saturating_sub(hit.inner_rect.x), + at: std::time::Instant::now(), + }; + if mouse.modifiers.is_empty() + && previous_pane_click + .as_ref() + .is_some_and(|previous| previous.is_double_click_for(&click)) + { + self.request_word_selection( + &hit, + click.viewport_row, + click.col, + outcome, + ); + } else { + if mouse.modifiers.is_empty() { + self.last_pane_click = Some(click); + } + self.selection = Some(crate::selection::Selection::anchor( + hit.pane_id.clone(), + mouse.row.saturating_sub(hit.inner_rect.y), + mouse.column.saturating_sub(hit.inner_rect.x), + hit.scroll, + )); + } + } + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id, + }), + outcome, + ); + } + } + MouseEventKind::Down(MouseButton::Middle) => { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point) && hit.mouse_reporting) + .cloned() + { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit, + button: MouseButton::Middle, + stripped_modifiers: crossterm::event::KeyModifiers::empty(), + last_event: mouse, + }); + } + } + MouseEventKind::Up(MouseButton::Left | MouseButton::Middle) + | MouseEventKind::Drag(MouseButton::Left | MouseButton::Middle) => {} + MouseEventKind::Moved => { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point) && hit.mouse_reporting) + .cloned() + { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + } + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point)) + .cloned() + { + if self.focused_pane_id().as_deref() != Some(hit.pane_id.as_str()) { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id.clone(), + }), + outcome, + ); + } + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + } + _ => {} + } + } + + fn pane_mouse_position(&self, hit: &PaneHit, mouse: MouseEvent) -> ClientMousePosition { + let cell = ClientMousePosition::Cell { + column: mouse.column.saturating_sub(hit.inner_rect.x), + row: mouse.row.saturating_sub(hit.inner_rect.y), + }; + if hit.sgr_pixel_mouse && hit.pixel_width > 0 && hit.pixel_height > 0 { + self.host_mouse_pixels + .and_then(|pixels| { + pixels + .pane_position(hit.inner_rect, hit.pixel_width, hit.pixel_height) + .and_then(|position| match position { + crate::input::mouse::Position::Pixels { x, y } => { + Some(ClientMousePosition::Pixels { + x, + y, + column: mouse.column.saturating_sub(hit.inner_rect.x), + row: mouse.row.saturating_sub(hit.inner_rect.y), + }) + } + crate::input::mouse::Position::Cell { .. } => None, + }) + }) + .unwrap_or(cell) + } else { + cell + } + } + + pub(super) fn push_pane_mouse_event( + &self, + hit: &PaneHit, + mouse: MouseEvent, + modifiers: crossterm::event::KeyModifiers, + outcome: &mut ClientShellInput, + ) { + let Some(kind) = crate::protocol::ClientMouseKind::from_crossterm(mouse.kind) else { + return; + }; + let position = self.pane_mouse_position(hit, mouse); + let geometry = matches!(position, ClientMousePosition::Pixels { .. }).then_some( + crate::protocol::ClientMouseGeometry { + cols: hit.inner_rect.width, + rows: hit.inner_rect.height, + width_px: hit.pixel_width, + height_px: hit.pixel_height, + }, + ); + let target = if hit.popup { + ClientInputTarget::Popup(hit.pane_id.clone()) + } else { + ClientInputTarget::Pane(hit.pane_id.clone()) + }; + push_target_event( + target, + ClientPaneInputEvent::Mouse { + kind, + position, + geometry, + modifiers: modifiers.bits(), + lines: self.config.mouse_scroll_lines.min(u16::MAX as usize) as u16, + }, + outcome, + ); + } +} diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs new file mode 100644 index 00000000..0310e57d --- /dev/null +++ b/src/client/shell/notifications.rs @@ -0,0 +1,418 @@ +use super::*; +use ratatui::{ + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Widget}, +}; + +pub(super) fn render_mobile_notification_banner( + buffer: &mut Buffer, + area: Rect, + notification: &ClientVisibleNotification, + offset_for_warning: bool, + palette: &Palette, +) -> Rect { + if area.is_empty() { + return Rect::default(); + } + let warning_offset = u16::from(offset_for_warning); + let y = area.y + + area + .height + .saturating_sub(1u16.saturating_add(warning_offset)); + let rect = Rect::new(area.x, y, area.width, 1); + let background = palette.surface0; + Clear.render(rect, buffer); + buffer.set_style(rect, Style::default().bg(background)); + let event = ¬ification.event; + let title = match event.kind { + SemanticNotificationKind::NeedsAttention => event + .title + .strip_suffix(" needs attention") + .map(|agent| format!("{agent} waiting")) + .unwrap_or_else(|| event.title.clone()), + SemanticNotificationKind::Finished => event + .title + .strip_suffix(" finished") + .map(|agent| format!("{agent} done")) + .unwrap_or_else(|| event.title.clone()), + SemanticNotificationKind::UpdateInstalled => "update ready".to_owned(), + SemanticNotificationKind::Custom => event.title.clone(), + }; + let dot_color = match event.kind { + SemanticNotificationKind::NeedsAttention => palette.red, + SemanticNotificationKind::Finished => palette.blue, + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => { + palette.accent + } + }; + let mut x = rect.x; + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + " ", + Style::default().bg(background), + ); + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + "●", + Style::default().fg(dot_color).bg(background), + ); + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + " ", + Style::default().bg(background), + ); + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + &title, + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ); + if let Some(body) = event.body.as_deref().filter(|body| !body.is_empty()) { + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + " · ", + Style::default().fg(palette.overlay0).bg(background), + ); + super::render::put_text( + buffer, + x, + rect.y, + rect.right().saturating_sub(x), + body, + Style::default().fg(palette.overlay0).bg(background), + ); + } + rect +} + +pub(super) fn render_visible_notification( + buffer: &mut Buffer, + area: Rect, + notification: &ClientVisibleNotification, + default_position: crate::config::ToastHerdrPosition, + top_offset: u16, + palette: &Palette, +) -> Rect { + if area.is_empty() { + return Rect::default(); + } + let event = ¬ification.event; + let body = event.body.as_deref().unwrap_or_default(); + let content_width = unicode_width::UnicodeWidthStr::width(event.title.as_str()) + .max(unicode_width::UnicodeWidthStr::width(body)) + .saturating_add(6); + let width = u16::try_from(content_width) + .unwrap_or(u16::MAX) + .min(area.width); + let height: u16 = if body.is_empty() { 3 } else { 4 }.min(area.height); + let position = event.position.unwrap_or(default_position); + let x = match position { + crate::config::ToastHerdrPosition::TopLeft + | crate::config::ToastHerdrPosition::BottomLeft => area.x, + crate::config::ToastHerdrPosition::TopRight + | crate::config::ToastHerdrPosition::BottomRight => area.right().saturating_sub(width), + }; + let max_y = area.bottom().saturating_sub(height).max(area.y); + let y = match position { + crate::config::ToastHerdrPosition::TopLeft + | crate::config::ToastHerdrPosition::TopRight => area.y.saturating_add(top_offset), + crate::config::ToastHerdrPosition::BottomLeft + | crate::config::ToastHerdrPosition::BottomRight => area + .bottom() + .saturating_sub(height.saturating_add(top_offset)), + } + .clamp(area.y, max_y); + let rect = Rect::new(x, y, width, height); + Clear.render(rect, buffer); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.overlay0)) + .style(Style::default().bg(palette.panel_bg)); + let inner = block.inner(rect); + block.render(rect, buffer); + Paragraph::new(Line::from(vec![ + Span::styled( + "●", + Style::default().fg(match event.kind { + SemanticNotificationKind::NeedsAttention => palette.red, + SemanticNotificationKind::Finished => palette.blue, + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => { + palette.accent + } + }), + ), + Span::raw(" "), + Span::styled( + event.title.as_str(), + Style::default() + .fg(palette.text) + .add_modifier(Modifier::BOLD), + ), + ])) + .render(Rect::new(inner.x, inner.y, inner.width, 1), buffer); + if !body.is_empty() && inner.height > 1 { + Paragraph::new(Line::from(Span::styled( + body, + Style::default().fg(palette.overlay0), + ))) + .render( + Rect::new( + inner.x.saturating_add(2), + inner.y + 1, + inner.width.saturating_sub(2), + 1, + ), + buffer, + ); + } + rect +} + +impl ClientShellState { + pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) { + let Some(notification) = self.visible_notification.take() else { + return; + }; + outcome.repaint = true; + if let Some(pane_id) = notification.event.pane_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } + } + + pub(crate) fn receive_notification( + &mut self, + event: SemanticNotification, + now: std::time::Instant, + ) -> (Vec<ClientShellNotificationEffect>, bool) { + let delay = if event.kind == SemanticNotificationKind::Custom { + 0 + } else { + self.config.toast_delay_seconds + }; + let deadline = now + .checked_add(std::time::Duration::from_secs(delay)) + .unwrap_or(now); + let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| { + self.visible_notification + .as_ref() + .is_some_and(|visible| visible.event.pane_id.as_deref() == Some(pane_id)) + }); + if let Some(pane_id) = event.pane_id.as_deref() { + self.pending_notifications + .retain(|pending| pending.event.pane_id.as_deref() != Some(pane_id)); + if cleared_visible { + self.visible_notification = None; + } + } + self.pending_notifications.push(ClientPendingNotification { + event, + deadline, + validate_state: delay > 0, + }); + let (effects, repaint) = self.tick_notifications(now); + (effects, repaint || cleared_visible) + } + + pub(crate) fn tick_notifications( + &mut self, + now: std::time::Instant, + ) -> (Vec<ClientShellNotificationEffect>, bool) { + let mut repaint = false; + if self + .visible_notification + .as_ref() + .is_some_and(|visible| now >= visible.deadline) + { + self.visible_notification = None; + repaint = true; + } + + let pending = std::mem::take(&mut self.pending_notifications); + let mut effects = Vec::new(); + for pending in pending { + if pending.deadline > now { + self.pending_notifications.push(pending); + continue; + } + if pending.validate_state && !self.notification_still_current(&pending.event) { + continue; + } + let target_active = self.notification_target_is_active(&pending.event); + let suppress_external = target_active && self.outer_focused != Some(false); + if let Some(sound) = pending.event.sound { + let suppress_sound = + pending.event.kind == SemanticNotificationKind::Finished && suppress_external; + if !suppress_sound { + effects.push(ClientShellNotificationEffect::Sound { + sound: match sound { + SemanticNotificationSound::Done => crate::sound::Sound::Done, + SemanticNotificationSound::Request => crate::sound::Sound::Request, + }, + agent: pending.event.agent.clone(), + }); + } + } + + match self.config.toast_delivery { + crate::config::ToastDelivery::Off => {} + crate::config::ToastDelivery::Herdr if !target_active => { + let duration = match pending.event.kind { + SemanticNotificationKind::NeedsAttention => 8, + SemanticNotificationKind::Finished => 5, + SemanticNotificationKind::UpdateInstalled => 3, + SemanticNotificationKind::Custom => 5, + }; + self.visible_notification = Some(ClientVisibleNotification { + event: pending.event, + deadline: now + std::time::Duration::from_secs(duration), + }); + repaint = true; + } + crate::config::ToastDelivery::Herdr => {} + crate::config::ToastDelivery::Terminal if !suppress_external => { + effects.push(ClientShellNotificationEffect::Terminal { + title: pending.event.title, + body: pending.event.body, + }); + } + crate::config::ToastDelivery::System if !suppress_external => { + effects.push(ClientShellNotificationEffect::System { + title: pending.event.title, + body: pending.event.body, + }); + } + crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {} + } + } + (effects, repaint) + } + + fn notification_target_is_active(&self, event: &SemanticNotification) -> bool { + let Some(snapshot) = self.snapshot.as_deref() else { + return false; + }; + if let Some(tab_id) = event.tab_id.as_deref() { + return snapshot.focused_tab_id.as_deref() == Some(tab_id); + } + event.workspace_id.as_deref().is_some_and(|workspace_id| { + snapshot.focused_workspace_id.as_deref() == Some(workspace_id) + }) + } + + fn notification_still_current(&self, event: &SemanticNotification) -> bool { + let Some(pane_id) = event.pane_id.as_deref() else { + return true; + }; + let Some(agent) = self.snapshot.as_deref().and_then(|snapshot| { + snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + }) else { + return false; + }; + match event.kind { + SemanticNotificationKind::NeedsAttention => { + agent.agent_status == crate::api::schema::AgentStatus::Blocked + } + SemanticNotificationKind::Finished => matches!( + agent.agent_status, + crate::api::schema::AgentStatus::Idle | crate::api::schema::AgentStatus::Done + ), + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn notification() -> ClientVisibleNotification { + ClientVisibleNotification { + event: SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "notice".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }, + deadline: std::time::Instant::now(), + } + } + + #[test] + fn mobile_notification_is_a_bottom_banner_with_released_title() { + let palette = crate::app::client_palette_from_config(&Config::default()); + let mut notification = notification(); + notification.event.kind = SemanticNotificationKind::NeedsAttention; + notification.event.title = "pi needs attention".into(); + notification.event.body = Some("workspace · tab 1".into()); + let area = Rect::new(0, 0, 44, 20); + let mut buffer = Buffer::empty(area); + for cell in &mut buffer.content { + cell.set_symbol("X"); + } + let rect = + render_mobile_notification_banner(&mut buffer, area, ¬ification, true, &palette); + assert_eq!(rect, Rect::new(0, 18, 44, 1)); + let text = buffer + .content + .iter() + .map(|cell| cell.symbol()) + .collect::<String>(); + assert!(text.contains("pi waiting")); + assert!(text.contains("workspace · tab 1")); + assert!(buffer.content[18 * 44..19 * 44] + .iter() + .all(|cell| cell.symbol() != "X")); + } + + #[test] + fn notification_rect_stays_inside_short_nonzero_area() { + let palette = crate::app::client_palette_from_config(&Config::default()); + for height in [1, 2] { + let area = Rect::new(3, 4, 8, height); + for position in [ + crate::config::ToastHerdrPosition::TopRight, + crate::config::ToastHerdrPosition::BottomRight, + ] { + let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 10)); + let rect = render_visible_notification( + &mut buffer, + area, + ¬ification(), + position, + 1, + &palette, + ); + assert!(rect.y >= area.y); + assert!(rect.bottom() <= area.bottom()); + } + } + } +} diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs new file mode 100644 index 00000000..154b00b6 --- /dev/null +++ b/src/client/shell/overlay_input.rs @@ -0,0 +1,1091 @@ +use super::*; + +impl ClientShellState { + pub(super) fn dismiss_product_announcement(&mut self, outcome: &mut ClientShellInput) { + let announcement = match self.overlay.take() { + Some(ClientShellOverlay::ProductAnnouncement(announcement)) => announcement, + other => { + self.overlay = other; + return; + } + }; + if self.snapshot.is_none() { + self.overlay = Some(ClientShellOverlay::ProductAnnouncement(announcement)); + return; + } + self.dismissed_product_announcement = + Some((announcement.version.clone(), announcement.id.clone())); + self.chrome_drag = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ProductAnnouncementDismiss( + crate::api::schema::ProductAnnouncementDismissParams { + version: announcement.version.clone(), + id: announcement.id.clone(), + }, + ), + PendingEndpointKind::ProductAnnouncementDismiss { + version: announcement.version, + id: announcement.id, + }, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn scroll_product_announcement(&mut self, delta: isize) { + let max_scroll = self.hits.product_announcement_max_scroll; + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = self.overlay.as_mut() { + let current = usize::from(announcement.scroll); + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as usize) + } + .min(max_scroll); + announcement.scroll = u16::try_from(next).unwrap_or(u16::MAX); + } + } + + pub(super) fn set_product_announcement_offset_from_bottom( + &mut self, + offset_from_bottom: usize, + ) { + let max_scroll = self.hits.product_announcement_max_scroll; + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = self.overlay.as_mut() { + announcement.scroll = + u16::try_from(max_scroll.saturating_sub(offset_from_bottom.min(max_scroll))) + .unwrap_or(u16::MAX); + } + } + + pub(super) fn open_release_notes(&mut self) { + let Some(notes) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.release_notes.as_ref()) + else { + return; + }; + self.overlay = Some(ClientShellOverlay::ReleaseNotes(release_notes_state(notes))); + self.chrome_drag = None; + } + + pub(super) fn dismiss_release_notes(&mut self, outcome: &mut ClientShellInput) { + let notes = match self.overlay.take() { + Some(ClientShellOverlay::ReleaseNotes(notes)) => notes, + other => { + self.overlay = other; + return; + } + }; + self.chrome_drag = None; + self.mode = if self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.as_deref()) + .is_some() + { + ClientShellMode::Terminal + } else { + ClientShellMode::Navigate + }; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: notes.version.clone(), + }, + ), + PendingEndpointKind::ReleaseNotesDismiss, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn current_release_notes_input_geometry( + &self, + ) -> Option<(Rect, Option<Rect>, crate::pane::ScrollMetrics)> { + let notes = match self.overlay.as_ref()? { + ClientShellOverlay::ReleaseNotes(notes) => notes, + _ => return None, + }; + let (cols, rows) = self.last_composed_size?; + let outer = crate::ui::centered_popup_rect( + Rect::new(0, 0, cols, rows), + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + )?; + let inner = Rect::new( + outer.x.saturating_add(1), + outer.y.saturating_add(1), + outer.width.saturating_sub(2), + outer.height.saturating_sub(2), + ); + if inner.height < 8 || inner.width < 20 { + return None; + } + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + let install_command = self + .snapshot + .as_deref() + .map(|snapshot| snapshot.update_install_command.as_str()) + .unwrap_or_default(); + let metrics = crate::ui::release_notes_scroll_metrics( + notes, + install_command, + stack.content, + &self.config.palette, + ); + let track = crate::ui::release_notes_scrollbar_rect(stack.content, metrics); + Some((close, track, metrics)) + } + + fn current_release_notes_max_scroll(&self) -> usize { + self.current_release_notes_input_geometry() + .map(|(_, _, metrics)| metrics.max_offset_from_bottom) + .unwrap_or(self.hits.release_notes_max_scroll) + } + + pub(super) fn scroll_release_notes(&mut self, delta: isize) { + let max_scroll = self.current_release_notes_max_scroll(); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + let current = usize::from(notes.scroll); + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as usize) + } + .min(max_scroll); + notes.scroll = u16::try_from(next).unwrap_or(u16::MAX); + } + } + + pub(super) fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) { + let max_scroll = self.current_release_notes_max_scroll(); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = + u16::try_from(max_scroll.saturating_sub(offset_from_bottom.min(max_scroll))) + .unwrap_or(u16::MAX); + } + } + + pub(super) fn complete_onboarding(&mut self, outcome: &mut ClientShellInput) { + if self.snapshot.is_none() { + return; + } + if let Err(error) = crate::config::update_file_at( + &self.config.local_config_path, + "onboarding setting", + |content| crate::config::upsert_top_level_bool(content, "onboarding", false), + ) { + self.set_local_config_diagnostic(Some(error)); + } + self.config.startup_onboarding = false; + self.open_settings_overlay(); + self.select_settings_section(ClientSettingsSection::Integrations, outcome); + } + + pub(super) fn open_navigator_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let expanded_workspaces = snapshot + .workspaces + .iter() + .map(|workspace| workspace.workspace_id.clone()) + .collect(); + let mut navigator = ClientNavigatorOverlay { + query: String::new(), + search_focused: false, + selected: 0, + scroll: 0, + filter: None, + expanded_workspaces, + }; + let rows = render::client_navigator_rows(snapshot, &navigator); + navigator.selected = rows.iter().position(|row| row.current).unwrap_or(0); + self.overlay = Some(ClientShellOverlay::Navigator(navigator)); + } + + pub(super) fn move_navigator_selection(&mut self, delta: isize) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else { + return; + }; + let rows = render::client_navigator_rows(snapshot, navigator); + if rows.is_empty() { + navigator.selected = 0; + return; + } + navigator.selected = (navigator.selected as isize + delta) + .clamp(0, rows.len().saturating_sub(1) as isize) as usize; + } + + pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) { + let target = { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { + return; + }; + render::client_navigator_rows(snapshot, navigator) + .get(navigator.selected) + .map(|row| row.target.clone()) + }; + let Some(target) = target else { + return; + }; + self.overlay = None; + let method = match target { + ClientNavigatorTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id, + }) + } + ClientNavigatorTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + } + ClientNavigatorTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) + } + }; + self.push_endpoint_method(method, outcome); + outcome.repaint = true; + } + + pub(super) fn toggle_selected_navigator_workspace(&mut self) { + let workspace_id = { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { + return; + }; + render::client_navigator_rows(snapshot, navigator) + .get(navigator.selected) + .and_then(|row| match &row.target { + ClientNavigatorTarget::Workspace(workspace_id) => Some(workspace_id.clone()), + _ => None, + }) + }; + if let (Some(workspace_id), Some(ClientShellOverlay::Navigator(navigator))) = + (workspace_id, self.overlay.as_mut()) + { + if !navigator.expanded_workspaces.remove(&workspace_id) { + navigator.expanded_workspaces.insert(workspace_id); + } + navigator.selected = 0; + navigator.scroll = 0; + } + } + + pub(super) fn workspace_action_id(&self) -> Option<String> { + self.navigate_workspace_id.clone().or_else(|| { + self.snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()) + }) + } + + pub(super) fn open_new_workspace_overlay(&mut self) { + let source_workspace_id = self.workspace_action_id(); + let cwd = self.snapshot.as_deref().and_then(|snapshot| { + let workspace_id = source_workspace_id.as_deref()?; + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + .map(|workspace| workspace.new_workspace_cwd.clone()) + }); + let suggested_name = cwd + .as_deref() + .map(std::path::Path::new) + .map(crate::workspace::derive_label_from_cwd) + .unwrap_or_else(|| "workspace".to_owned()); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "new workspace", + input: suggested_name.clone(), + replace_on_type: true, + target: ClientRenameTarget::NewWorkspace { + source_workspace_id, + cwd, + suggested_name, + }, + })); + } + + pub(super) fn open_rename_workspace_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace_id) = self.workspace_action_id() else { + return; + }; + let Some(workspace) = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + else { + return; + }; + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename workspace", + input: workspace.label.clone(), + replace_on_type: false, + target: ClientRenameTarget::Workspace { workspace_id }, + })); + } + + pub(super) fn open_new_tab_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace_id) = snapshot.focused_workspace_id.clone() else { + return; + }; + let default_name = (snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .count() + + 1) + .to_string(); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "new tab", + input: default_name.clone(), + replace_on_type: true, + target: ClientRenameTarget::NewTab { + workspace_id, + default_name, + }, + })); + } + + pub(super) fn open_rename_tab_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(tab_id) = snapshot.focused_tab_id.as_deref() else { + return; + }; + let Some(tab) = snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id) else { + return; + }; + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename tab", + input: tab.label.clone(), + replace_on_type: false, + target: ClientRenameTarget::Tab { + tab_id: tab.tab_id.clone(), + auto_name: !tab.custom_label, + original_name: tab.label.clone(), + }, + })); + } + + pub(super) fn open_rename_pane_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(pane_id) = snapshot.focused_pane_id.as_deref() else { + return; + }; + let Some(pane) = snapshot.panes.iter().find(|pane| pane.pane_id == pane_id) else { + return; + }; + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: pane.label.clone().unwrap_or_default(), + replace_on_type: pane.label.is_none(), + target: ClientRenameTarget::Pane { + pane_id: pane.pane_id.clone(), + }, + })); + } + + pub(super) fn insert_overlay_text(&mut self, text: &str) -> bool { + if self.insert_worktree_overlay_text(text) { + return true; + } + match self.overlay.as_mut() { + Some(ClientShellOverlay::Rename(rename)) => { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + } + rename.input.push_str(text); + true + } + Some(ClientShellOverlay::Help(help)) if help.search_focused => { + help.query + .extend(text.chars().filter(|character| !character.is_control())); + help.scroll = 0; + true + } + Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused => { + navigator.query.push_str(text); + navigator.filter = None; + navigator.selected = 0; + true + } + _ => false, + } + } + + pub(super) fn route_overlay_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + use crossterm::event::KeyModifiers; + + if matches!(self.overlay, Some(ClientShellOverlay::Onboarding)) { + if matches!( + key.code, + KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') + ) { + self.complete_onboarding(outcome); + } + return; + } + + if matches!( + self.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + ) { + match key.code { + KeyCode::Enter | KeyCode::Esc => self.dismiss_product_announcement(outcome), + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_product_announcement(-1); + outcome.repaint = true; + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll_product_announcement(1); + outcome.repaint = true; + } + KeyCode::PageUp => { + self.scroll_product_announcement(-8); + outcome.repaint = true; + } + KeyCode::PageDown => { + self.scroll_product_announcement(8); + outcome.repaint = true; + } + KeyCode::Home => { + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = + self.overlay.as_mut() + { + announcement.scroll = 0; + } + outcome.repaint = true; + } + KeyCode::End => { + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = + self.overlay.as_mut() + { + announcement.scroll = + u16::try_from(self.hits.product_announcement_max_scroll) + .unwrap_or(u16::MAX); + } + outcome.repaint = true; + } + _ => {} + } + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::ReleaseNotes(_))) { + match key.code { + KeyCode::Enter | KeyCode::Esc => self.dismiss_release_notes(outcome), + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_release_notes(-1); + outcome.repaint = true; + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll_release_notes(1); + outcome.repaint = true; + } + KeyCode::PageUp => { + self.scroll_release_notes(-8); + outcome.repaint = true; + } + KeyCode::PageDown => { + self.scroll_release_notes(8); + outcome.repaint = true; + } + KeyCode::Home => { + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = 0; + } + outcome.repaint = true; + } + KeyCode::End => { + let max_scroll = self.current_release_notes_max_scroll(); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = u16::try_from(max_scroll).unwrap_or(u16::MAX); + } + outcome.repaint = true; + } + _ => {} + } + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::GlobalMenu(_))) { + match key.code { + KeyCode::Esc => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Up | KeyCode::Char('k') => { + self.move_global_menu_selection(-1); + outcome.repaint = true; + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_global_menu_selection(1); + outcome.repaint = true; + } + KeyCode::Enter => { + let highlighted = match self.overlay.as_ref() { + Some(ClientShellOverlay::GlobalMenu(menu)) => menu.highlighted, + _ => return, + }; + self.activate_global_menu_item(highlighted, outcome); + } + _ => {} + } + return; + } + + if self.route_settings_key(key, outcome) { + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::ContextMenu(_))) { + match key.code { + KeyCode::Esc => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Up => { + self.move_context_menu_selection(-1); + outcome.repaint = true; + } + KeyCode::Down => { + self.move_context_menu_selection(1); + outcome.repaint = true; + } + KeyCode::Enter => { + let highlighted = match self.overlay.as_ref() { + Some(ClientShellOverlay::ContextMenu(menu)) => menu.highlighted, + _ => return, + }; + self.activate_context_menu_item(highlighted, outcome); + } + _ => {} + } + return; + } + + if self.route_worktree_overlay_key(key, outcome) { + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Navigator(_))) { + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + search_focused: true, + .. + })) + ); + if code == KeyCode::Esc { + if search_focused { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.search_focused = false; + } + } else { + self.overlay = None; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Enter { + self.accept_navigator_selection(outcome); + return; + } + if search_focused { + if code == KeyCode::Up + || code == KeyCode::Char('p') && modifiers.contains(KeyModifiers::CONTROL) + { + self.move_navigator_selection(-1); + outcome.repaint = true; + return; + } + if code == KeyCode::Down + || code == KeyCode::Char('n') && modifiers.contains(KeyModifiers::CONTROL) + { + self.move_navigator_selection(1); + outcome.repaint = true; + return; + } + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) { + navigator.query.clear(); + navigator.filter = None; + navigator.selected = 0; + } else if code == KeyCode::Backspace { + navigator.query.pop(); + navigator.filter = None; + navigator.selected = 0; + } else if let KeyCode::Char(character) = code { + if modifiers.difference(KeyModifiers::SHIFT).is_empty() { + navigator.filter = None; + if let Some(text) = key.generated_text.as_deref() { + navigator.query.push_str(text); + } else { + navigator.query.push(character); + } + navigator.selected = 0; + } + } + outcome.repaint = true; + } + return; + } + if code == KeyCode::Backspace && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + if navigator.filter.take().is_some() { + navigator.selected = 0; + } + } + outcome.repaint = true; + return; + } + if code == KeyCode::Home && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.selected = 0; + navigator.scroll = 0; + } + outcome.repaint = true; + return; + } + if matches!(code, KeyCode::End | KeyCode::Char('G')) && modifiers.is_empty() { + let last = self + .snapshot + .as_deref() + .zip(self.overlay.as_ref()) + .and_then(|(snapshot, overlay)| match overlay { + ClientShellOverlay::Navigator(navigator) => { + Some(render::client_navigator_rows(snapshot, navigator).len()) + } + _ => None, + }) + .unwrap_or(0) + .saturating_sub(1); + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.selected = last; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Char('/') && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.search_focused = true; + navigator.filter = None; + } + outcome.repaint = true; + return; + } + if matches!(code, KeyCode::Down | KeyCode::Char('j')) && modifiers.is_empty() { + self.move_navigator_selection(1); + outcome.repaint = true; + return; + } + if matches!(code, KeyCode::Up | KeyCode::Char('k')) && modifiers.is_empty() { + self.move_navigator_selection(-1); + outcome.repaint = true; + return; + } + if code == KeyCode::Char('d') && modifiers.contains(KeyModifiers::CONTROL) { + self.move_navigator_selection(8); + outcome.repaint = true; + return; + } + if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) { + self.move_navigator_selection(-8); + outcome.repaint = true; + return; + } + if let Some(filter) = match code { + KeyCode::Char('b') if modifiers.is_empty() => Some(ClientNavigatorFilter::Blocked), + KeyCode::Char('w') if modifiers.is_empty() => Some(ClientNavigatorFilter::Working), + KeyCode::Char('i') if modifiers.is_empty() => Some(ClientNavigatorFilter::Idle), + KeyCode::Char('d') if modifiers.is_empty() => Some(ClientNavigatorFilter::Done), + _ => None, + } { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.query.clear(); + navigator.filter = Some(filter); + navigator.selected = 0; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Char('a') && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.query.clear(); + navigator.filter = None; + navigator.selected = 0; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Char(' ') && modifiers.is_empty() { + self.toggle_selected_navigator_workspace(); + outcome.repaint = true; + return; + } + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::Help(_))) { + let text_character = crate::input::keybind_help_text_char(key); + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: true, + .. + })) + ); + if search_focused { + match code { + KeyCode::Esc => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.search_focused = false; + help.query.clear(); + help.scroll = 0; + } + } + KeyCode::Enter => self.overlay = None, + KeyCode::Home => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = 0; + } + } + KeyCode::End => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = self.hits.help_max_scroll; + } + } + KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown => { + let delta = match code { + KeyCode::Up => -1, + KeyCode::Down => 1, + KeyCode::PageUp => -8, + KeyCode::PageDown => 8, + _ => unreachable!(), + }; + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = help + .scroll + .saturating_add_signed(delta) + .min(self.hits.help_max_scroll); + } + } + KeyCode::Backspace => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.query.pop(); + help.scroll = 0; + } + } + KeyCode::Char('u') if modifiers == KeyModifiers::CONTROL => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.query.clear(); + help.scroll = 0; + } + } + _ => { + if let Some(character) = text_character { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.query.push(character); + help.scroll = 0; + } + } + } + } + outcome.repaint = true; + return; + } + + match code { + KeyCode::Esc | KeyCode::Enter => self.overlay = None, + KeyCode::Home => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = 0; + } + } + KeyCode::End => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = self.hits.help_max_scroll; + } + } + KeyCode::Up + | KeyCode::Char('k') + | KeyCode::Down + | KeyCode::Char('j') + | KeyCode::PageUp + | KeyCode::PageDown => { + let delta = match code { + KeyCode::Up | KeyCode::Char('k') => -1, + KeyCode::Down | KeyCode::Char('j') => 1, + KeyCode::PageUp => -8, + KeyCode::PageDown => 8, + _ => unreachable!(), + }; + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = help + .scroll + .saturating_add_signed(delta) + .min(self.hits.help_max_scroll); + } + } + _ if text_character == Some('/') => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.search_focused = true; + help.scroll = 0; + } + } + _ if text_character == Some('?') => self.overlay = None, + _ => {} + } + outcome.repaint = true; + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::ConfirmClose(_))) { + if key.code == KeyCode::Enter { + let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() else { + return; + }; + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id: confirm.workspace_id, + close_group: true, + }, + ), + outcome, + ); + outcome.repaint = true; + } else if key.code == KeyCode::Esc { + self.overlay = None; + self.mode = ClientShellMode::Navigate; + self.navigate_workspace_id = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()); + outcome.repaint = true; + } + return; + } + + let Some(ClientShellOverlay::Rename(rename)) = self.overlay.as_mut() else { + return; + }; + if key.code == KeyCode::Enter { + self.save_rename_overlay(outcome); + return; + } + if key.code == KeyCode::Esc { + self.overlay = None; + outcome.repaint = true; + return; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + rename.input.clear(); + rename.replace_on_type = false; + outcome.repaint = true; + return; + } + if (key.code == KeyCode::Char('u') && key.modifiers.contains(KeyModifiers::CONTROL)) + || (key.code == KeyCode::Backspace && key.modifiers.contains(KeyModifiers::SUPER)) + { + rename.input.clear(); + rename.replace_on_type = false; + outcome.repaint = true; + return; + } + if key.code == KeyCode::Backspace + && (key.modifiers.contains(KeyModifiers::CONTROL) + || key.modifiers.contains(KeyModifiers::ALT)) + || matches!(key.code, KeyCode::Char('h' | 'w')) + && key.modifiers.contains(KeyModifiers::CONTROL) + { + delete_overlay_word(rename); + outcome.repaint = true; + return; + } + if key.code == KeyCode::Backspace { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + } else { + rename.input.pop(); + } + outcome.repaint = true; + return; + } + if let KeyCode::Char(character) = key.code { + if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + } + if let Some(text) = key.generated_text.as_deref() { + rename.input.push_str(text); + } else { + rename.input.push(character); + } + outcome.repaint = true; + } + } + } + + pub(super) fn save_rename_overlay(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::Rename(rename)) = self.overlay.take() else { + return; + }; + let trimmed = rename.input.trim(); + let method = match rename.target { + ClientRenameTarget::NewWorkspace { + source_workspace_id, + cwd, + suggested_name, + } => Some(crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + source_workspace_id, + cwd, + focus: true, + label: (!trimmed.is_empty() && trimmed != suggested_name) + .then(|| trimmed.to_owned()), + env: Default::default(), + }, + )), + ClientRenameTarget::Workspace { workspace_id } => (!trimmed.is_empty()).then(|| { + crate::api::schema::Method::WorkspaceRename( + crate::api::schema::WorkspaceRenameParams { + workspace_id, + label: trimmed.to_owned(), + }, + ) + }), + ClientRenameTarget::NewTab { + workspace_id, + default_name, + } => Some(crate::api::schema::Method::TabCreate( + crate::api::schema::TabCreateParams { + workspace_id: Some(workspace_id), + cwd: None, + focus: true, + label: (!trimmed.is_empty() && trimmed != default_name) + .then(|| trimmed.to_owned()), + env: Default::default(), + }, + )), + ClientRenameTarget::Tab { + tab_id, + auto_name, + original_name, + } => (!(trimmed.is_empty() || auto_name && trimmed == original_name)).then(|| { + crate::api::schema::Method::TabRename(crate::api::schema::TabRenameParams { + tab_id, + label: trimmed.to_owned(), + }) + }), + ClientRenameTarget::Pane { pane_id } => Some(crate::api::schema::Method::PaneRename( + crate::api::schema::PaneRenameParams { + pane_id, + label: Some(trimmed.to_owned()), + }, + )), + }; + if let Some(method) = method { + self.push_endpoint_method(method, outcome); + } + outcome.repaint = true; + } + + pub(super) fn open_confirm_close_overlay(&mut self, workspace_id: String) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace) = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + else { + return; + }; + let group_key = workspace + .worktree + .as_ref() + .filter(|worktree| !worktree.is_linked_worktree) + .map(|worktree| worktree.key.as_str()); + let group = group_key + .map(|key| { + snapshot + .workspaces + .iter() + .filter(|member| { + member + .worktree + .as_ref() + .is_some_and(|worktree| worktree.key == key) + }) + .collect::<Vec<_>>() + }) + .unwrap_or_else(|| vec![workspace]); + let closes_group = group.len() > 1; + let pane_count = group + .iter() + .map(|member| { + snapshot + .panes + .iter() + .filter(|pane| pane.workspace_id == member.workspace_id) + .count() + }) + .sum::<usize>(); + let panes = if pane_count == 1 { + "1 pane".to_owned() + } else { + format!("{pane_count} panes") + }; + let scope = if closes_group { + format!("{} workspaces, {panes}", group.len()) + } else { + panes + }; + self.overlay = Some(ClientShellOverlay::ConfirmClose( + ClientConfirmCloseOverlay { + workspace_id, + title: if closes_group { + "Close worktree group?".to_owned() + } else { + "Close workspace?".to_owned() + }, + detail: format!("{} — {scope}", workspace.label), + }, + )); + } +} diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs new file mode 100644 index 00000000..30ac78b9 --- /dev/null +++ b/src/client/shell/overlays.rs @@ -0,0 +1,1169 @@ +use super::*; + +mod settings_overlay; +mod worktree_overlays; + +#[derive(Default)] +pub(crate) struct OverlayRender { + pub(crate) primary: Rect, + pub(crate) clear: Rect, + pub(crate) cancel: Rect, + pub(crate) navigator_popup: Rect, + pub(crate) navigator_search: Rect, + pub(crate) navigator_rows: Vec<(Rect, usize)>, + pub(crate) worktree_search: Rect, + pub(crate) worktree_rows: Vec<(Rect, usize)>, + pub(crate) help_popup: Rect, + pub(crate) help_scrollbar: Rect, + pub(crate) help_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(crate) help_max_scroll: usize, + pub(crate) settings_popup: Rect, + pub(crate) settings_tabs: Vec<(Rect, ClientSettingsSection)>, + pub(crate) settings_choices: Vec<(Rect, usize)>, + pub(crate) product_announcement_scrollbar: Rect, + pub(crate) product_announcement_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(crate) product_announcement_max_scroll: usize, + pub(crate) release_notes_scrollbar: Rect, + pub(crate) release_notes_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(crate) release_notes_max_scroll: usize, + pub(crate) cursor: Option<crate::protocol::CursorState>, +} + +pub(crate) fn render_client_overlay( + b: &mut Buffer, + o: &ClientShellOverlay, + s: &ClientShellSnapshot, + k: &LiveKeybindConfig, + p: &Palette, +) -> Option<OverlayRender> { + if !matches!( + o, + ClientShellOverlay::Navigator(_) + | ClientShellOverlay::ContextMenu(_) + | ClientShellOverlay::GlobalMenu(_) + ) { + for y in b.area.y..b.area.bottom() { + for x in b.area.x..b.area.right() { + let c = &mut b[(x, y)]; + c.set_style(c.style().add_modifier(Modifier::DIM)); + } + } + } + match o { + ClientShellOverlay::Onboarding => render_onboarding_overlay(b, p), + ClientShellOverlay::ProductAnnouncement(v) => render_product_announcement_overlay(b, v, p), + ClientShellOverlay::ReleaseNotes(v) => { + render_release_notes_overlay(b, v, &s.update_install_command, p) + } + ClientShellOverlay::Rename(v) => render_rename_overlay(b, v, p), + ClientShellOverlay::ConfirmClose(v) => render_confirm_close_overlay(b, v, p), + ClientShellOverlay::Help(v) => render_help_overlay(b, v, k, p), + ClientShellOverlay::Navigator(v) => render_navigator_overlay(b, v, s, p), + ClientShellOverlay::Settings(v) => { + settings_overlay::render_settings_overlay(b, v, s.integration_updates_available, p) + } + ClientShellOverlay::WorktreeCreate(v) => { + worktree_overlays::render_worktree_create_overlay(b, v, p) + } + ClientShellOverlay::WorktreeOpen(v) => { + worktree_overlays::render_worktree_open_overlay(b, v, p) + } + ClientShellOverlay::WorktreeRemove(v) => { + worktree_overlays::render_worktree_remove_overlay(b, v, p) + } + ClientShellOverlay::ContextMenu(_) | ClientShellOverlay::GlobalMenu(_) => None, + } +} + +pub(crate) fn render_global_menu( + buffer: &mut Buffer, + launcher: Rect, + menu: &ClientGlobalMenuOverlay, + snapshot: &ClientShellSnapshot, + palette: &Palette, +) -> Option<Vec<(Rect, usize)>> { + let items = super::super::global_menu::global_menu_items(snapshot); + let screen = buffer.area; + let width = items + .iter() + .map(|(label, action)| { + display_width(label) + + u16::from(super::super::global_menu::global_menu_item_has_badge( + snapshot, *action, + )) * 2 + }) + .max() + .unwrap_or(8) + .saturating_add(4) + .min(screen.width.max(1)); + let height = (items.len() as u16) + .saturating_add(2) + .min(screen.height.max(1)); + let x = launcher + .right() + .saturating_sub(width) + .min(screen.right().saturating_sub(width)); + let y = launcher.y.saturating_sub(height).max(screen.y); + let inner = panel( + buffer, + Rect::new(x, y, width, height), + palette.accent, + palette.panel_bg, + )?; + let mut rows = Vec::new(); + for (index, (label, action)) in items.iter().enumerate() { + let row_y = inner.y.saturating_add(index as u16); + if row_y >= inner.bottom() { + break; + } + let row = Rect::new(inner.x, row_y, inner.width, 1); + let highlighted = index == menu.highlighted; + let style = if highlighted { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.panel_bg) + }; + buffer.set_style(row, style); + let has_badge = super::super::global_menu::global_menu_item_has_badge(snapshot, *action); + if has_badge { + let badge_style = if highlighted { + style + } else { + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD) + }; + put_text(buffer, row.x, row.y, row.width.min(2), " ●", badge_style); + put_text( + buffer, + row.x.saturating_add(2), + row.y, + row.width.saturating_sub(2), + &format!(" {label}"), + style, + ); + } else { + put_text(buffer, row.x, row.y, row.width, &format!(" {label}"), style); + } + rows.push((row, index)); + } + Some(rows) +} + +pub(crate) fn render_context_menu( + buffer: &mut Buffer, + menu: &ClientContextMenuOverlay, + palette: &Palette, +) -> Option<Vec<(Rect, usize)>> { + let items = menu.items(); + let screen = buffer.area; + let max_item_width = items + .iter() + .map(|item| display_width(item.label)) + .max() + .unwrap_or(0); + let width = max_item_width + .saturating_add(4) + .max(14) + .min(screen.width.max(1)); + let height = (items.len() as u16) + .saturating_add(2) + .min(screen.height.max(1)); + let x = menu + .x + .min(screen.x.saturating_add(screen.width.saturating_sub(width))); + let y = menu.y.min( + screen + .y + .saturating_add(screen.height.saturating_sub(height)), + ); + let rect = Rect::new(x, y, width, height); + let inner = panel(buffer, rect, palette.accent, palette.panel_bg)?; + let mut rows = Vec::new(); + for (index, item) in items.iter().enumerate() { + let row_y = inner.y.saturating_add(index as u16); + if row_y >= inner.bottom() { + break; + } + let row = Rect::new(inner.x, row_y, inner.width, 1); + let highlighted = index == menu.highlighted; + let style = if highlighted { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.panel_bg) + }; + buffer.set_style(row, style); + put_text(buffer, row.x, row.y, row.width, item.label, style); + rows.push((row, index)); + } + Some(rows) +} + +fn panel( + b: &mut Buffer, + a: Rect, + c: ratatui::style::Color, + bg: ratatui::style::Color, +) -> Option<Rect> { + if a.width < 2 || a.height < 2 { + return None; + } + let background = Style::default().bg(bg).remove_modifier(Modifier::DIM); + let border = Style::default().fg(c).bg(bg).remove_modifier(Modifier::DIM); + for y in a.y..a.bottom() { + for x in a.x..a.right() { + b[(x, y)].set_symbol(" ").set_style(background); + } + } + for x in a.x..a.right() { + b[(x, a.y)] + .set_symbol(if x == a.x { + "┌" + } else if x + 1 == a.right() { + "┐" + } else { + "─" + }) + .set_style(border); + let y = a.bottom() - 1; + b[(x, y)] + .set_symbol(if x == a.x { + "└" + } else if x + 1 == a.right() { + "┘" + } else { + "─" + }) + .set_style(border); + } + for y in a.y + 1..a.bottom() - 1 { + b[(a.x, y)].set_symbol("│").set_style(border); + b[(a.right() - 1, y)].set_symbol("│").set_style(border); + } + Some(Rect::new(a.x + 1, a.y + 1, a.width - 2, a.height - 2)) +} +fn popup(a: Rect, w: u16, h: u16) -> Option<Rect> { + let w = w.min(a.width.saturating_sub(4)); + let h = h.min(a.height.saturating_sub(2)); + if w < 4 || h < 4 { + return None; + } + Some(Rect::new( + a.x + (a.width - w) / 2, + a.y + (a.height - h) / 2, + w, + h, + )) +} +fn button(b: &mut Buffer, r: Rect, t: &str, s: Style) { + b.set_style(r, s); + let w = display_width(t).min(r.width); + put_text(b, r.x + (r.width - w) / 2, r.y, w, t, s) +} +fn row(i: Rect, ws: &[u16], gap: u16, off: u16) -> Vec<Rect> { + let total = ws.iter().sum::<u16>() + gap * (ws.len().saturating_sub(1) as u16); + let mut x = i.x + i.width.saturating_sub(total) / 2; + ws.iter() + .map(|w| { + let r = Rect::new( + x, + i.y + off.min(i.height.saturating_sub(1)), + (*w).min(i.width.saturating_sub(x - i.x)), + 1, + ); + x += *w + gap; + r + }) + .collect() +} +fn contrast(p: &Palette) -> ratatui::style::Color { + match p.panel_bg { + ratatui::style::Color::Reset => p.surface_dim, + c => c, + } +} +fn render_release_notes_overlay( + b: &mut Buffer, + notes: &crate::app::state::ReleaseNotesState, + install_command: &str, + p: &Palette, +) -> Option<OverlayRender> { + let outer = popup( + b.area, + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + )?; + let inner = panel(b, outer, p.accent, p.panel_bg)?; + if inner.height < 8 || inner.width < 20 { + return Some(OverlayRender::default()); + } + + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let title_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y, + stack.header.width.saturating_sub(2), + 1, + ); + let subtitle_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y.saturating_add(1), + stack.header.width.saturating_sub(2), + 1, + ); + let base = Style::default() + .bg(p.panel_bg) + .remove_modifier(Modifier::DIM); + put_text( + b, + title_area.x, + title_area.y, + title_area.width, + &format!("v{}", notes.version), + base.fg(p.text).add_modifier(Modifier::BOLD), + ); + put_text( + b, + subtitle_area.x, + subtitle_area.y, + subtitle_area.width, + if notes.preview { + "update ready" + } else { + "what's new in this release" + }, + base.fg(p.overlay1), + ); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + button( + b, + close, + " esc close ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM), + ); + + let body = stack.content; + let lines = crate::ui::release_notes_display_lines(notes, install_command, p); + let metrics = crate::ui::release_notes_scroll_metrics(notes, install_command, body, p); + let max_scroll = metrics.max_offset_from_bottom; + let scroll = usize::from(notes.scroll).min(max_scroll); + let track = crate::ui::release_notes_scrollbar_rect(body, metrics); + let text_area = track + .map(|_| Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height)) + .unwrap_or(body); + let paragraph = ratatui::widgets::Paragraph::new( + lines.into_iter().map(|(_, line)| line).collect::<Vec<_>>(), + ) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); + ratatui::widgets::Widget::render(paragraph, text_area, b); + if let Some(track) = track { + crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐"); + } + + if let Some(footer_area) = stack.footer { + let footer_line = ratatui::text::Line::from(vec![ + ratatui::text::Span::styled(" scroll ", base.fg(p.overlay0)), + ratatui::text::Span::styled("wheel ↑↓", base.fg(p.text)), + ratatui::text::Span::styled(" · ", base.fg(p.overlay0)), + ratatui::text::Span::styled("close", base.fg(p.overlay0)), + ratatui::text::Span::styled(" esc / enter ", base.fg(p.text)), + ]); + ratatui::widgets::Widget::render( + ratatui::widgets::Paragraph::new(footer_line), + footer_area, + b, + ); + } + + Some(OverlayRender { + primary: close, + release_notes_scrollbar: track.unwrap_or_default(), + release_notes_scroll_metrics: Some(metrics), + release_notes_max_scroll: max_scroll, + ..OverlayRender::default() + }) +} + +fn render_product_announcement_overlay( + b: &mut Buffer, + announcement: &crate::app::state::ProductAnnouncementState, + p: &Palette, +) -> Option<OverlayRender> { + let outer = popup( + b.area, + crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, + crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1, + )?; + let inner = panel(b, outer, p.accent, p.panel_bg)?; + if inner.height < 8 || inner.width < 20 { + return Some(OverlayRender::default()); + } + + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let title_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y, + stack.header.width.saturating_sub(2), + 1, + ); + let subtitle_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y.saturating_add(1), + stack.header.width.saturating_sub(2), + 1, + ); + let base = Style::default() + .bg(p.panel_bg) + .remove_modifier(Modifier::DIM); + put_text( + b, + title_area.x, + title_area.y, + title_area.width, + &announcement.title, + base.fg(p.text).add_modifier(Modifier::BOLD), + ); + let subtitle = if announcement.preview { + "product announcement preview" + } else { + "product announcement" + }; + put_text( + b, + subtitle_area.x, + subtitle_area.y, + subtitle_area.width, + &format!("{subtitle} · v{}", announcement.version), + base.fg(p.overlay1), + ); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + button( + b, + close, + " esc close ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM), + ); + + let body = stack.content; + let lines = crate::ui::product_announcement_display_lines(announcement, p); + let metrics = crate::ui::product_announcement_scroll_metrics(announcement, body, p); + let max_scroll = metrics.max_offset_from_bottom; + let scroll = usize::from(announcement.scroll).min(max_scroll); + let track = crate::ui::release_notes_scrollbar_rect(body, metrics); + let text_area = track + .map(|_| Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height)) + .unwrap_or(body); + let paragraph = ratatui::widgets::Paragraph::new( + lines.into_iter().map(|(_, line)| line).collect::<Vec<_>>(), + ) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); + ratatui::widgets::Widget::render(paragraph, text_area, b); + if let Some(track) = track { + crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐"); + } + + if let Some(footer_area) = stack.footer { + let footer_line = ratatui::text::Line::from(vec![ + ratatui::text::Span::styled(" scroll ", base.fg(p.overlay0)), + ratatui::text::Span::styled("wheel ↑↓", base.fg(p.text)), + ratatui::text::Span::styled(" · ", base.fg(p.overlay0)), + ratatui::text::Span::styled("close", base.fg(p.overlay0)), + ratatui::text::Span::styled(" esc / enter ", base.fg(p.text)), + ]); + ratatui::widgets::Widget::render( + ratatui::widgets::Paragraph::new(footer_line), + footer_area, + b, + ); + } + + Some(OverlayRender { + primary: close, + product_announcement_scrollbar: track.unwrap_or_default(), + product_announcement_scroll_metrics: Some(metrics), + product_announcement_max_scroll: max_scroll, + ..OverlayRender::default() + }) +} + +fn render_onboarding_overlay(b: &mut Buffer, p: &Palette) -> Option<OverlayRender> { + let outer = popup(b.area, 64, 16)?; + let inner = panel(b, outer, p.accent, p.panel_bg)?; + if inner.height < 11 { + return Some(OverlayRender::default()); + } + let stack = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1); + let base = Style::default() + .bg(p.panel_bg) + .remove_modifier(Modifier::DIM); + let title = base.fg(p.text).add_modifier(Modifier::BOLD); + let muted = base.fg(p.overlay0); + let text = base.fg(p.overlay1); + let accent = base.fg(p.accent).add_modifier(Modifier::BOLD); + + put_text( + b, + stack.header.x, + stack.header.y, + stack.header.width, + crate::ui::ONBOARDING_TITLE, + title, + ); + put_text( + b, + stack.header.x, + stack.header.y.saturating_add(1), + stack.header.width, + crate::ui::ONBOARDING_SUBTITLE, + muted, + ); + + let content = stack.content; + for (offset, line) in crate::ui::ONBOARDING_DESCRIPTION.iter().enumerate() { + put_text( + b, + content.x, + content.y.saturating_add(offset as u16), + content.width, + line, + text, + ); + } + + let key_y = content.y.saturating_add(4); + let mut key_x = content.x; + for (value, style) in [ + (" ", base), + (crate::ui::ONBOARDING_PREFIX_LABEL, accent), + (crate::ui::ONBOARDING_PREFIX_SUFFIX, text), + (crate::ui::ONBOARDING_HELP_LABEL, accent), + (crate::ui::ONBOARDING_HELP_SUFFIX, text), + ] { + let width = display_width(value); + put_text( + b, + key_x, + key_y, + content.right().saturating_sub(key_x), + value, + style, + ); + key_x = key_x.saturating_add(width); + } + put_text( + b, + content.x, + content.y.saturating_add(5), + content.width, + crate::ui::ONBOARDING_NEXT, + text, + ); + + let primary = crate::ui::onboarding_welcome_continue_rect(stack.actions.unwrap_or_default()); + button( + b, + primary, + " ↵ continue ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM), + ); + Some(OverlayRender { + primary, + ..OverlayRender::default() + }) +} + +fn render_rename_overlay( + b: &mut Buffer, + v: &ClientRenameOverlay, + p: &Palette, +) -> Option<OverlayRender> { + let q = popup(b.area, 56, 7)?; + let i = panel(b, q, p.accent, p.panel_bg)?; + put_text( + b, + i.x, + i.y, + i.width, + v.title, + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + let input = Rect::new(i.x, i.y + 2, i.width, 1); + b.set_style(input, Style::default().fg(p.text).bg(p.surface0)); + put_text( + b, + input.x, + input.y, + input.width.saturating_sub(1), + &format!(" {}", v.input), + Style::default().fg(p.text).bg(p.surface0), + ); + let rs = row(i, &[8, 10, 12], 2, 3); + let [save, clear, cancel] = rs.as_slice() else { + return None; + }; + button( + b, + *save, + " ↵ save ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + let n = Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD); + button(b, *clear, " ^c clear ", n); + button(b, *cancel, " esc cancel ", n); + Some(OverlayRender { + primary: *save, + clear: *clear, + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: Some(crate::protocol::CursorState { + x: (input.x + 1 + display_width(&v.input)).min(input.right() - 1), + y: input.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +pub(crate) fn client_navigator_rows( + s: &ClientShellSnapshot, + n: &ClientNavigatorOverlay, +) -> Vec<ClientNavigatorRow> { + let q = n.query.trim().to_lowercase(); + let filter = |st| match n.filter { + Some(ClientNavigatorFilter::Blocked) => st == crate::api::schema::AgentStatus::Blocked, + Some(ClientNavigatorFilter::Working) => st == crate::api::schema::AgentStatus::Working, + Some(ClientNavigatorFilter::Idle) => st == crate::api::schema::AgentStatus::Idle, + Some(ClientNavigatorFilter::Done) => st == crate::api::schema::AgentStatus::Done, + None => true, + }; + let text = |v: &str| q.is_empty() || v.to_lowercase().contains(&q); + let filtering = n.filter.is_some() || !q.is_empty(); + let mut out = Vec::new(); + for w in &s.workspaces { + let mut children = Vec::new(); + for t in s.tabs.iter().filter(|t| t.workspace_id == w.workspace_id) { + let mut panes = Vec::new(); + for (ix, p) in s.panes.iter().filter(|p| p.tab_id == t.tab_id).enumerate() { + let a = s.agents.iter().find(|a| a.pane_id == p.pane_id); + let st = a + .map(|a| a.agent_status) + .unwrap_or(crate::api::schema::AgentStatus::Unknown); + let label = p + .label + .clone() + .or_else(|| a.and_then(|a| a.name.clone())) + .or_else(|| a.and_then(|a| a.display_agent.clone())) + .or_else(|| a.and_then(|a| a.title.clone())) + .unwrap_or_else(|| format!("pane {}", ix + 1)); + let meta = p + .foreground_cwd + .clone() + .or_else(|| p.cwd.clone()) + .unwrap_or_default(); + if !filtering || filter(st) && (text(&label) || text(&meta)) { + panes.push(ClientNavigatorRow { + depth: 2, + label, + meta, + status: st, + current: s.focused_pane_id.as_deref() == Some(&p.pane_id), + target: ClientNavigatorTarget::Pane(p.pane_id.clone()), + }) + } + } + if !filtering || filter(t.agent_status) && text(&t.label) || !panes.is_empty() { + children.push(ClientNavigatorRow { + depth: 1, + label: t.label.clone(), + meta: format!( + "{} panes", + s.panes.iter().filter(|p| p.tab_id == t.tab_id).count() + ), + status: t.agent_status, + current: s.focused_tab_id.as_deref() == Some(&t.tab_id), + target: ClientNavigatorTarget::Tab(t.tab_id.clone()), + }); + children.extend(panes) + } + } + let wm = + filter(w.agent_status) && (text(&w.label) || w.branch.as_deref().is_some_and(&text)); + if !filtering || wm || !children.is_empty() { + out.push(ClientNavigatorRow { + depth: 0, + label: w.label.clone(), + meta: w.branch.clone().unwrap_or_default(), + status: w.agent_status, + current: s.focused_workspace_id.as_deref() == Some(&w.workspace_id), + target: ClientNavigatorTarget::Workspace(w.workspace_id.clone()), + }); + if n.expanded_workspaces.contains(&w.workspace_id) || filtering { + out.extend(children) + } + } + } + out +} + +fn render_navigator_overlay( + b: &mut Buffer, + n: &ClientNavigatorOverlay, + s: &ClientShellSnapshot, + p: &Palette, +) -> Option<OverlayRender> { + let a = b.area; + let mx = (a.width / 16).max(2); + let my = (a.height / 10).max(1); + let q = Rect::new( + a.x + mx, + a.y + my, + a.width.saturating_sub(mx * 2).max(4), + a.height.saturating_sub(my * 2).max(4), + ); + let i = panel(b, q, p.accent, p.panel_bg)?; + let rows = client_navigator_rows(s, n); + let search = if n.search_focused { + format!(" / {}", n.query) + } else if let Some(f) = n.filter { + format!( + " / {}", + match f { + ClientNavigatorFilter::Blocked => "blocked", + ClientNavigatorFilter::Working => "working", + ClientNavigatorFilter::Idle => "idle", + ClientNavigatorFilter::Done => "done", + } + ) + } else if n.query.is_empty() { + " / search panes".to_owned() + } else { + format!(" / {}", n.query) + }; + put_text( + b, + i.x, + i.y, + i.width, + &search, + Style::default() + .fg(if n.search_focused { p.text } else { p.overlay0 }) + .bg(p.panel_bg), + ); + put_right_text( + b, + i, + i.y, + &format!("{} panes", s.panes.len()), + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + put_text( + b, + i.x, + i.y + 1, + i.width, + &"─".repeat(i.width as usize), + Style::default().fg(p.surface1).bg(p.panel_bg), + ); + let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4)); + let max = rows.len().saturating_sub(body.height as usize); + let scroll = n + .scroll + .max( + n.selected + .saturating_sub(body.height.saturating_sub(1) as usize), + ) + .min(n.selected) + .min(max); + let mut row_hits = Vec::new(); + for (vis, (ix, r)) in rows + .iter() + .enumerate() + .skip(scroll) + .take(body.height as usize) + .enumerate() + { + let rect = Rect::new(body.x, body.y + vis as u16, body.width, 1); + row_hits.push((rect, ix)); + let st = if ix == n.selected { + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(if r.current { p.text } else { p.subtext0 }) + .bg(p.panel_bg) + }; + b.set_style(rect, st); + let tree = match r.depth { + 0 => match &r.target { + ClientNavigatorTarget::Workspace(id) if n.expanded_workspaces.contains(id) => "▾", + _ => "▸", + }, + 1 => "└──", + _ => " └──", + }; + let label = format!( + " {} {} {} {}", + if r.current { "◆" } else { " " }, + tree, + status_dot(r.status), + r.label + ); + put_text(b, rect.x, rect.y, rect.width, &label, st); + if !r.meta.is_empty() { + let label_width = display_width(&label).min(rect.width); + let meta = Rect::new( + rect.x.saturating_add(label_width).saturating_add(1), + rect.y, + rect.width.saturating_sub(label_width.saturating_add(1)), + 1, + ); + put_right_text(b, meta, rect.y, &r.meta, st) + } + } + let dy = i.bottom() - 2; + if let Some(r) = rows.get(n.selected) { + put_text( + b, + i.x, + dy, + i.width, + &format!(" {} · {}", r.label, r.meta), + Style::default().fg(p.overlay0).bg(p.panel_bg), + ) + } + put_text( + b, + i.x, + i.bottom() - 1, + i.width, + if n.search_focused { + " search type · move ↑↓/ctrl+n/p · open enter · back esc" + } else { + " move j/k · expand space · filter a/b/w/i/d · search / · open enter · close esc" + }, + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + Some(OverlayRender { + primary: Rect::default(), + clear: Rect::default(), + cancel: Rect::default(), + navigator_popup: q, + navigator_search: Rect::new(i.x, i.y, i.width, 1), + navigator_rows: row_hits, + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: n.search_focused.then(|| crate::protocol::CursorState { + x: i.x + 3 + display_width(&n.query), + y: i.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +fn help_lines( + keybinds: &LiveKeybindConfig, + query: &str, + palette: &Palette, +) -> Vec<(usize, ratatui::text::Line<'static>)> { + use ratatui::text::{Line, Span}; + + let groups = crate::input::filter_keybind_help_groups( + crate::input::keybind_help_groups(&keybinds.keybinds, keybinds.prefix), + query, + ); + let key_width = groups + .iter() + .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count())) + .max() + .unwrap_or(8); + if groups.is_empty() { + let message = " no matching keybinds"; + return vec![( + message.chars().count(), + Line::from(Span::styled( + message, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + )), + )]; + } + + let mut lines = Vec::new(); + for (group, entries) in groups { + lines.push(( + group.len() + 1, + Line::from(Span::styled( + format!(" {group}"), + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + )), + )); + for (key, label) in entries { + let padded_key = format!(" {key:<key_width$} "); + let width = padded_key.chars().count() + label.chars().count(); + lines.push(( + width, + Line::from(vec![ + Span::styled( + padded_key, + Style::default() + .fg(palette.mauve) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + label.into_owned(), + Style::default().fg(palette.text).bg(palette.panel_bg), + ), + ]), + )); + } + lines.push((0, Line::raw(""))); + } + lines +} + +fn render_help_overlay( + b: &mut Buffer, + h: &ClientHelpOverlay, + k: &LiveKeybindConfig, + p: &Palette, +) -> Option<OverlayRender> { + use ratatui::widgets::{Paragraph, Widget, Wrap}; + + let q = popup(b.area, 76, 22)?; + let i = panel(b, q, p.accent, p.panel_bg)?; + if i.width < 20 || i.height < 6 { + return None; + } + put_text( + b, + i.x, + i.y, + i.width, + "keybinds", + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + let close = Rect::new(i.right() - 13, i.y, 13, 1); + button( + b, + close, + if h.search_focused { + " esc back " + } else { + " esc close " + }, + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + let sy = i.y + 1; + put_text( + b, + i.x, + sy, + i.width, + &if h.search_focused { + format!(" / {}", h.query) + } else { + " / press / to filter by command or shortcut".to_owned() + }, + Style::default() + .fg(if h.search_focused { p.text } else { p.overlay0 }) + .bg(p.panel_bg), + ); + + let body = Rect::new(i.x, i.y + 3, i.width, i.height.saturating_sub(5)); + let lines = help_lines(k, &h.query, p); + let viewport_rows = usize::from(body.height.max(1)); + let wrapped_rows = |width: u16| { + let width = usize::from(width.max(1)); + lines + .iter() + .map(|(line_width, _)| line_width.max(&1).div_ceil(width)) + .sum::<usize>() + }; + let needs_scrollbar = wrapped_rows(body.width) > viewport_rows; + let text_area = if needs_scrollbar { + Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height) + } else { + body + }; + let total_rows = wrapped_rows(text_area.width); + let max_scroll = total_rows.saturating_sub(viewport_rows); + let scroll = h.scroll.min(max_scroll); + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: max_scroll.saturating_sub(scroll), + max_offset_from_bottom: max_scroll, + viewport_rows, + }; + let scrollbar = needs_scrollbar.then_some(Rect::new( + body.right().saturating_sub(1), + body.y, + 1, + body.height, + )); + Widget::render( + Paragraph::new(lines.into_iter().map(|(_, line)| line).collect::<Vec<_>>()) + .wrap(Wrap { trim: false }) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)), + text_area, + b, + ); + if let Some(track) = scrollbar { + if let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) { + for y in track.y..track.bottom() { + b[(track.x, y)] + .set_symbol("▐") + .set_style(Style::default().fg(p.overlay0).bg(p.panel_bg)); + } + for y in thumb.top..thumb.top.saturating_add(thumb.len) { + b[(track.x, y)] + .set_symbol("▐") + .set_style(Style::default().fg(p.overlay1).bg(p.panel_bg)); + } + } + } + + put_text( + b, + i.x, + i.bottom() - 1, + i.width, + if h.search_focused { + " filter type/backspace · clear ctrl+u · scroll ↑↓/pgup/pgdn · back esc" + } else { + " search / · scroll j/k/↑↓/pgup/pgdn · close esc/enter" + }, + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + Some(OverlayRender { + cancel: close, + help_popup: q, + help_scrollbar: scrollbar.unwrap_or_default(), + help_scroll_metrics: Some(metrics), + help_max_scroll: max_scroll, + cursor: h.search_focused.then(|| crate::protocol::CursorState { + x: (i.x + 3 + display_width(&h.query)).min(i.right() - 1), + y: sy, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} +fn render_confirm_close_overlay( + b: &mut Buffer, + c: &ClientConfirmCloseOverlay, + p: &Palette, +) -> Option<OverlayRender> { + let q = popup(b.area, 64, 6)?; + let i = panel(b, q, p.red, p.panel_bg)?; + put_text( + b, + i.x, + i.y, + i.width, + &format!(" {}", c.title), + Style::default() + .fg(p.red) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + b, + i.x, + i.y + 1, + i.width, + &format!(" {}", c.detail), + Style::default().fg(p.text).bg(p.panel_bg), + ); + let rs = row(i, &[13, 12], 2, 3); + let [ok, cancel] = rs.as_slice() else { + return None; + }; + button( + b, + *ok, + " ↵ confirm ", + Style::default() + .fg(contrast(p)) + .bg(p.red) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *ok, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: None, + ..OverlayRender::default() + }) +} diff --git a/src/client/shell/preferences.rs b/src/client/shell/preferences.rs new file mode 100644 index 00000000..dc6cc1ac --- /dev/null +++ b/src/client/shell/preferences.rs @@ -0,0 +1,132 @@ +use std::{ + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1); + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ClientChromePreferences { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) sidebar_width: Option<u16>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) sidebar_section_split: Option<f32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) sidebar_collapsed: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) agent_panel_sort: Option<crate::config::AgentPanelSortConfig>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(super) collapsed_groups: Vec<String>, +} + +pub(super) fn path_for_local_endpoint(socket_path: &Path) -> PathBuf { + let mut hash = 0xcbf29ce484222325u64; + for byte in socket_path.to_string_lossy().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + crate::config::state_dir() + .join("client-shell") + .join(format!("local-{hash:016x}.json")) +} + +pub(super) fn load(path: &Path) -> Option<ClientChromePreferences> { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +pub(super) fn store(path: &Path, preferences: ClientChromePreferences) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("invalid client shell state path: {}", path.display()))?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create client shell state directory: {error}"))?; + let content = serde_json::to_vec_pretty(&preferences) + .map_err(|error| format!("failed to encode client shell state: {error}"))?; + let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed); + let mut temp_name = path + .file_name() + .ok_or_else(|| format!("invalid client shell state path: {}", path.display()))? + .to_os_string(); + temp_name.push(format!(".tmp-{}-{sequence}", std::process::id())); + let temp_path = parent.join(temp_name); + std::fs::write(&temp_path, content) + .map_err(|error| format!("failed to write client shell state: {error}"))?; + crate::platform::replace_file(&temp_path, path).map_err(|error| { + let _ = std::fs::remove_file(&temp_path); + format!("failed to replace client shell state: {error}") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_paths_are_stable_and_distinct() { + let first = path_for_local_endpoint(Path::new("/run/herdr/one.sock")); + let again = path_for_local_endpoint(Path::new("/run/herdr/one.sock")); + let second = path_for_local_endpoint(Path::new("/run/herdr/two.sock")); + assert_eq!(first, again); + assert_ne!(first, second); + } + + #[test] + fn concurrent_stores_leave_complete_preferences() { + let path = std::env::temp_dir().join(format!( + "herdr-shell-concurrent-preferences-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let writers = (20..28) + .map(|width| { + let path = path.clone(); + std::thread::spawn(move || { + store( + &path, + ClientChromePreferences { + sidebar_width: Some(width), + ..ClientChromePreferences::default() + }, + ) + }) + }) + .collect::<Vec<_>>(); + for writer in writers { + writer.join().expect("preference writer").expect("store"); + } + assert!(load(&path) + .and_then(|saved| saved.sidebar_width) + .is_some_and(|width| (20..28).contains(&width))); + std::fs::remove_file(path).expect("remove preferences"); + } + + #[test] + fn repeated_store_replaces_existing_preferences() { + let path = std::env::temp_dir().join(format!( + "herdr-shell-preferences-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + store( + &path, + ClientChromePreferences { + sidebar_width: Some(24), + ..ClientChromePreferences::default() + }, + ) + .expect("first preference store"); + store( + &path, + ClientChromePreferences { + sidebar_width: Some(32), + ..ClientChromePreferences::default() + }, + ) + .expect("replacement preference store"); + assert_eq!(load(&path).and_then(|saved| saved.sidebar_width), Some(32)); + std::fs::remove_file(path).expect("remove preferences"); + } +} diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs new file mode 100644 index 00000000..0e7f3908 --- /dev/null +++ b/src/client/shell/render.rs @@ -0,0 +1,313 @@ +use super::*; + +#[path = "../shell/overlays.rs"] +mod overlays; +#[path = "../shell/sidebar.rs"] +mod sidebar; +#[path = "../shell/tabs.rs"] +mod tabs; + +pub(super) use super::agent_sidebar::{ordered_agent_pane_ids, render_agent_panel}; +pub(super) use overlays::{ + client_navigator_rows, render_client_overlay, render_context_menu, render_global_menu, +}; +pub(super) use sidebar::{render_collapsed_sidebar, render_sidebar, workspace_entries}; +pub(super) use tabs::{render_tab_bar, tab_bar_status_width}; +pub(super) fn render_mode_bar( + buffer: &mut Buffer, + pane_area: Rect, + mode: ClientShellMode, + copy_mode: Option<&ClientCopyModeState>, + endpoint_error: Option<&str>, + update_available: bool, + keybinds: &LiveKeybindConfig, + palette: &Palette, +) -> Option<Rect> { + if (mode == ClientShellMode::Terminal && endpoint_error.is_none()) || pane_area.is_empty() { + return None; + } + + let bar = Rect::new( + pane_area.x, + pane_area.y + pane_area.height.saturating_sub(1), + pane_area.width, + 1, + ); + let base = Style::default().fg(palette.overlay0).bg(palette.panel_bg); + for x in bar.x..bar.x + bar.width { + buffer[(x, bar.y)].set_symbol(" ").set_style(base); + } + + let key = Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD); + let mode_style = Style::default() + .fg(match palette.panel_bg { + ratatui::style::Color::Reset => palette.surface_dim, + color => color, + }) + .bg(if mode == ClientShellMode::Resize { + palette.mauve + } else { + palette.accent + }) + .add_modifier(Modifier::BOLD); + let prefix = crate::config::format_key_combo(keybinds.prefix); + let prefix_rhs = |bindings: &crate::config::ActionKeybinds| { + bindings + .prefix_rhs_label() + .unwrap_or_else(|| "unset".to_owned()) + }; + + let mut segments = Vec::<(String, Style)>::new(); + if let Some(error) = endpoint_error { + segments.extend([ + (" ERROR ".to_owned(), mode_style), + (format!(" {error}"), base), + ]); + } else { + match mode { + ClientShellMode::Prefix => { + segments.extend([ + (" PREFIX ".to_owned(), mode_style), + (" ".to_owned(), base), + ("esc".to_owned(), key), + (" cancel ".to_owned(), base), + (prefix, key), + (" send prefix ".to_owned(), base), + (prefix_rhs(&keybinds.keybinds.workspace_picker), key), + (" workspace nav ".to_owned(), base), + (prefix_rhs(&keybinds.keybinds.help), key), + (" keybinds".to_owned(), base), + ]); + } + ClientShellMode::Navigate => { + segments.extend([ + (" NAVIGATE ".to_owned(), mode_style), + (" esc back ".to_owned(), base), + ("↑/↓".to_owned(), key), + (" workspace ".to_owned(), base), + ("tab".to_owned(), key), + (" pane ".to_owned(), base), + (prefix_rhs(&keybinds.keybinds.help), key), + (" keybinds".to_owned(), base), + ]); + } + ClientShellMode::Resize => { + segments.extend([ + (" RESIZE ".to_owned(), mode_style), + (" ".to_owned(), base), + ("h/l".to_owned(), key), + (" width ".to_owned(), base), + ("j/k".to_owned(), key), + (" height ".to_owned(), base), + ("esc".to_owned(), key), + (" done".to_owned(), base), + ]); + } + ClientShellMode::Copy => { + let copy_mode = copy_mode?; + if let Some(prompt) = copy_mode.search_prompt.as_ref() { + let marker = match prompt.direction { + crate::api::schema::PaneCopySearchDirection::Forward => "/", + crate::api::schema::PaneCopySearchDirection::Backward => "?", + }; + segments.extend([ + (" COPY ".to_owned(), mode_style), + (" ".to_owned(), base), + (marker.to_owned(), key), + ( + prompt.query.clone(), + Style::default().fg(palette.text).bg(palette.panel_bg), + ), + ("█".to_owned(), key), + (" enter search esc cancel".to_owned(), base), + ]); + } else { + let select = if copy_mode.selection.is_some() { + "selecting" + } else { + "select" + }; + let match_status = copy_mode + .search_current_global + .map(|current| format!(" {}/{}", current + 1, copy_mode.search_total)) + .or_else(|| (!copy_mode.search_query.is_empty()).then(|| " 0/0".to_owned())) + .unwrap_or_default(); + let (exit_keys, exit_label) = + if copy_mode.search_query.is_empty() && copy_mode.selection.is_none() { + ("q/esc", " exit") + } else { + ("esc", " clear q exit") + }; + segments.extend([ + (" COPY ".to_owned(), mode_style), + (" ".to_owned(), base), + ("h/j/k/l w/b/e { }".to_owned(), key), + (" move ".to_owned(), base), + ("/ ?".to_owned(), key), + (" search ".to_owned(), base), + ("n/N".to_owned(), key), + (format!(" repeat{match_status} "), base), + ("v/space".to_owned(), key), + (format!(" {select} "), base), + ("y/enter".to_owned(), key), + (" copy ".to_owned(), base), + (exit_keys.to_owned(), key), + (exit_label.to_owned(), base), + ]); + } + } + ClientShellMode::Terminal => unreachable!(), + } + } + + let mut x = bar.x; + let end = bar.x + bar.width; + for (text, style) in segments { + if x >= end { + break; + } + let remaining = end - x; + buffer.set_stringn(x, bar.y, &text, usize::from(remaining), style); + x = x.saturating_add( + u16::try_from(UnicodeWidthStr::width(text.as_str())) + .unwrap_or(u16::MAX) + .min(remaining), + ); + } + if update_available && mode == ClientShellMode::Navigate { + let width = 13.min(bar.width); + let area = Rect::new(bar.right().saturating_sub(width), bar.y, width, 1); + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + put_right_text( + buffer, + area, + area.y, + " update ready", + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + } + Some(bar) +} + +pub(super) struct ShellRenderState<'a> { + pub(super) collapsed_groups: &'a HashSet<String>, + pub(super) workspace_scroll: &'a mut usize, + pub(super) agent_scroll: &'a mut usize, + pub(super) tab_scroll: &'a mut usize, + pub(super) reveal_focused_tab: &'a mut bool, + pub(super) sidebar_collapsed: bool, + pub(super) sidebar_section_split: f32, + pub(super) tab_drag_insert_index: Option<usize>, + pub(super) selected_workspace_id: Option<&'a str>, + pub(super) dragged_workspace_id: Option<&'a str>, + pub(super) workspace_drop_indicator_row: Option<u16>, +} + +pub(super) fn render_shell( + buffer: &mut Buffer, + layout: ClientShellLayout, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + mut state: ShellRenderState<'_>, +) -> ShellHitMap { + let mut hits = ShellHitMap::default(); + if layout.mobile_header.height > 0 { + super::mobile::render_mobile_header( + buffer, + layout.mobile_header, + snapshot, + config, + &mut hits, + ); + } + if layout.sidebar.width > 0 { + if state.sidebar_collapsed { + render_collapsed_sidebar( + buffer, + layout.sidebar, + snapshot, + config, + state.selected_workspace_id, + &mut hits, + ); + } else { + render_sidebar( + buffer, + layout.sidebar, + snapshot, + config, + &mut state, + &mut hits, + ); + } + } + if layout.tab_bar.height > 0 { + render_tab_bar( + buffer, + layout.tab_bar, + snapshot, + config, + state.tab_scroll, + state.reveal_focused_tab, + state.tab_drag_insert_index, + &mut hits, + ); + } + if !config.mouse_capture { + hits.sidebar_divider = Rect::default(); + hits.sidebar_section_divider = Rect::default(); + hits.workspace_scrollbar = Rect::default(); + hits.agent_scrollbar = Rect::default(); + hits.agent_sort_toggle = Rect::default(); + hits.new_workspace = Rect::default(); + hits.workspaces.clear(); + hits.agents.clear(); + hits.tab_scroll_left = Rect::default(); + hits.tab_scroll_right = Rect::default(); + hits.new_tab = Rect::default(); + hits.pane_splits.clear(); + } + hits +} + +fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) { + let width = display_width(text).min(area.width); + put_text( + buffer, + area.right().saturating_sub(width), + y, + width, + text, + style, + ); +} + +pub(super) fn put_segment( + buffer: &mut Buffer, + x: u16, + y: u16, + right: u16, + text: &str, + style: Style, +) -> u16 { + let width = display_width(text).min(right.saturating_sub(x)); + put_text(buffer, x, y, width, text, style); + x.saturating_add(width) +} + +pub(super) fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) { + if width == 0 || y >= buffer.area.bottom() || x >= buffer.area.right() { + return; + } + buffer.set_stringn(x, y, text, width as usize, style); +} + +pub(super) fn display_width(text: &str) -> u16 { + UnicodeWidthStr::width(text).min(u16::MAX as usize) as u16 +} diff --git a/src/client/shell/scroll.rs b/src/client/shell/scroll.rs new file mode 100644 index 00000000..a0496cb8 --- /dev/null +++ b/src/client/shell/scroll.rs @@ -0,0 +1,98 @@ +use ratatui::{buffer::Buffer, layout::Rect, style::Style}; + +use super::Palette; + +pub(super) fn list_scroll_metrics( + row_heights: &[u16], + gaps_after: &[u16], + body_height: u16, + requested_start: usize, +) -> crate::pane::ScrollMetrics { + if row_heights.is_empty() || body_height == 0 { + return crate::pane::ScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 0, + }; + } + + let mut used = 0u16; + let mut max_start = row_heights.len(); + for index in (0..row_heights.len()).rev() { + let height = row_heights[index].max(1).min(body_height); + let gap = gaps_after.get(index).copied().unwrap_or(0); + if used.saturating_add(height).saturating_add(gap) > body_height { + break; + } + used = used.saturating_add(height).saturating_add(gap); + max_start = index; + } + max_start = max_start.min(row_heights.len().saturating_sub(1)); + let start = requested_start.min(max_start); + + let mut viewport_rows = 0usize; + let mut used = 0u16; + for (index, row_height) in row_heights.iter().enumerate().skip(start) { + let height = (*row_height).max(1).min(body_height); + if used.saturating_add(height) > body_height { + break; + } + used = used.saturating_add(height); + viewport_rows += 1; + let gap = gaps_after.get(index).copied().unwrap_or(0); + if used.saturating_add(gap) > body_height { + break; + } + used = used.saturating_add(gap); + } + + crate::pane::ScrollMetrics { + offset_from_bottom: max_start.saturating_sub(start), + max_offset_from_bottom: max_start, + viewport_rows, + } +} + +pub(super) fn render_list_scrollbar( + buffer: &mut Buffer, + track: Rect, + metrics: crate::pane::ScrollMetrics, + palette: &Palette, +) { + let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) else { + return; + }; + for row in track.y..track.bottom() { + if let Some(cell) = buffer.cell_mut((track.x, row)) { + cell.set_symbol("▕") + .set_style(Style::default().fg(palette.surface_dim)); + } + } + for row in thumb.top..thumb.top.saturating_add(thumb.len) { + if let Some(cell) = buffer.cell_mut((track.x, row)) { + cell.set_symbol("▕") + .set_style(Style::default().fg(palette.overlay0)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_metrics_preserve_variable_rows_and_caller_owned_gap_policy() { + let top = list_scroll_metrics(&[1, 3, 2], &[1, 1, 0], 5, 0); + assert_eq!(top.max_offset_from_bottom, 2); + assert_eq!(top.offset_from_bottom, 2); + assert_eq!(top.viewport_rows, 2); + + let bottom = list_scroll_metrics(&[1, 3, 2], &[1, 1, 0], 5, usize::MAX); + assert_eq!(bottom.offset_from_bottom, 0); + assert_eq!(bottom.viewport_rows, 1); + + let parent_child = list_scroll_metrics(&[2, 2, 2], &[0, 1, 0], 5, 0); + assert_eq!(parent_child.max_offset_from_bottom, 1); + assert_eq!(parent_child.viewport_rows, 2); + } +} diff --git a/src/client/shell/settings.rs b/src/client/shell/settings.rs new file mode 100644 index 00000000..95a437fe --- /dev/null +++ b/src/client/shell/settings.rs @@ -0,0 +1,385 @@ +use super::*; +use crossterm::event::{KeyCode, KeyModifiers}; + +pub(super) fn normalized_theme_name(name: &str) -> String { + name.to_lowercase().replace([' ', '_'], "-") +} + +fn theme_index(name: &str) -> usize { + let normalized = normalized_theme_name(name); + crate::config::THEME_NAMES + .iter() + .position(|candidate| normalized_theme_name(candidate) == normalized) + .unwrap_or(0) +} + +fn indicator_index(style: crate::config::StatusIndicatorStyle) -> usize { + usize::from(style == crate::config::StatusIndicatorStyle::Symbols) +} + +fn toast_index(delivery: crate::config::ToastDelivery) -> usize { + match delivery { + crate::config::ToastDelivery::Off => 0, + crate::config::ToastDelivery::Herdr => 1, + crate::config::ToastDelivery::Terminal => 2, + crate::config::ToastDelivery::System => 3, + } +} + +pub(super) fn integration_needs_install(info: &crate::api::schema::IntegrationInfo) -> bool { + info.state == crate::api::schema::IntegrationState::Outdated + || info.available && info.state == crate::api::schema::IntegrationState::NotInstalled +} + +impl ClientShellState { + pub(super) fn open_settings_overlay(&mut self) { + self.overlay = Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + selected: theme_index(&self.config.theme_name), + original_theme_name: self.config.theme_name.clone(), + original_palette: self.config.palette.clone(), + integrations: Vec::new(), + integration_messages: Vec::new(), + loading_integrations: false, + installing_integrations: false, + })); + } + + fn selected_index_for_settings_section(&self, section: ClientSettingsSection) -> usize { + match section { + ClientSettingsSection::Theme => theme_index(&self.config.theme_name), + ClientSettingsSection::Indicators => indicator_index(self.config.status_indicators), + ClientSettingsSection::Sound => usize::from(!self.config.sound_enabled), + ClientSettingsSection::Toast => toast_index(self.config.toast_delivery), + ClientSettingsSection::Integrations => 0, + } + } + + pub(super) fn select_settings_section( + &mut self, + section: ClientSettingsSection, + outcome: &mut ClientShellInput, + ) { + let selected = self.selected_index_for_settings_section(section); + let request_integrations = matches!(section, ClientSettingsSection::Integrations) + && matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + loading_integrations: false, + installing_integrations: false, + .. + })) + ); + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.section = section; + settings.selected = selected; + } + if request_integrations { + self.queue_integration_list(outcome, true); + } + outcome.repaint = true; + } + + fn move_settings_section(&mut self, delta: isize, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else { + return; + }; + let current = ClientSettingsSection::ALL + .iter() + .position(|section| *section == settings.section) + .unwrap_or(0); + let next = (current as isize + delta).rem_euclid(ClientSettingsSection::ALL.len() as isize) + as usize; + self.select_settings_section(ClientSettingsSection::ALL[next], outcome); + } + + fn settings_choice_count(&self) -> usize { + match self.overlay.as_ref() { + Some(ClientShellOverlay::Settings(settings)) => match settings.section { + ClientSettingsSection::Theme => crate::config::THEME_NAMES.len(), + ClientSettingsSection::Indicators | ClientSettingsSection::Sound => 2, + ClientSettingsSection::Toast => 4, + ClientSettingsSection::Integrations => settings.integrations.len(), + }, + _ => 0, + } + } + + pub(super) fn move_settings_selection(&mut self, delta: isize) { + let count = self.settings_choice_count(); + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() else { + return; + }; + if count == 0 { + settings.selected = 0; + return; + } + settings.selected = (settings.selected as isize + delta) + .clamp(0, count.saturating_sub(1) as isize) as usize; + if settings.section == ClientSettingsSection::Theme { + self.preview_selected_theme(); + } + } + + pub(super) fn select_settings_choice(&mut self, index: usize) { + let count = self.settings_choice_count(); + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + if count > 0 { + settings.selected = index.min(count - 1); + } + } + if matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + .. + })) + ) { + self.preview_selected_theme(); + } + } + + fn preview_selected_theme(&mut self) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else { + return; + }; + let Some(name) = crate::config::THEME_NAMES.get(settings.selected) else { + return; + }; + self.config.theme_name = (*name).to_owned(); + self.config.palette = + crate::app::client_palette_for_theme(&self.config.theme_runtime, name); + } + + pub(super) fn cancel_settings_overlay(&mut self) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.take() else { + return; + }; + self.config.theme_name = settings.original_theme_name; + self.config.palette = settings.original_palette; + } + + fn save_settings_edit( + &mut self, + edit: crate::config::ConfigEdit<'_>, + outcome: &mut ClientShellInput, + ) -> bool { + if let Err(error) = crate::config::write_edit(edit) { + self.endpoint_error = Some(error); + outcome.repaint = true; + return false; + } + self.reload_client_config(); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ServerReloadConfig( + crate::api::schema::EmptyParams::default(), + ), + PendingEndpointKind::ReloadConfig, + outcome, + ); + outcome.repaint = true; + true + } + + pub(super) fn apply_settings_choice(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else { + return; + }; + let section = settings.section; + let selected = settings.selected; + match section { + ClientSettingsSection::Theme => { + let Some(name) = crate::config::THEME_NAMES.get(selected).copied() else { + return; + }; + if self.save_settings_edit(crate::config::ConfigEdit::Theme(name), outcome) { + self.overlay = None; + } + } + ClientSettingsSection::Indicators => { + let style = if selected == 0 { + crate::config::StatusIndicatorStyle::Dots + } else { + crate::config::StatusIndicatorStyle::Symbols + }; + self.save_settings_edit( + crate::config::ConfigEdit::StatusIndicators(style), + outcome, + ); + } + ClientSettingsSection::Sound => { + self.save_settings_edit(crate::config::ConfigEdit::Sound(selected == 0), outcome); + } + ClientSettingsSection::Toast => { + let delivery = match selected { + 0 => crate::config::ToastDelivery::Off, + 1 => crate::config::ToastDelivery::Herdr, + 2 => crate::config::ToastDelivery::Terminal, + _ => crate::config::ToastDelivery::System, + }; + self.save_settings_edit( + crate::config::ConfigEdit::ToastDelivery(delivery), + outcome, + ); + } + ClientSettingsSection::Integrations => self.install_recommended_integrations(outcome), + } + } + + fn queue_integration_list(&mut self, outcome: &mut ClientShellInput, clear_messages: bool) { + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.loading_integrations = true; + if clear_messages { + settings.integration_messages.clear(); + } + } + self.push_endpoint_method_with_kind( + crate::api::schema::Method::IntegrationList(crate::api::schema::EmptyParams::default()), + PendingEndpointKind::IntegrationList, + outcome, + ); + } + + fn install_recommended_integrations(&mut self, outcome: &mut ClientShellInput) { + if self.pending_integration_installs > 0 { + return; + } + let targets = match self.overlay.as_ref() { + Some(ClientShellOverlay::Settings(settings)) => settings + .integrations + .iter() + .filter(|integration| integration_needs_install(integration)) + .map(|integration| integration.target) + .collect::<Vec<_>>(), + _ => return, + }; + if targets.is_empty() { + return; + } + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.installing_integrations = true; + settings.integration_messages.clear(); + } + self.pending_integration_installs = targets.len(); + for target in targets { + self.push_endpoint_method_with_kind( + crate::api::schema::Method::IntegrationInstall( + crate::api::schema::IntegrationInstallParams { target }, + ), + PendingEndpointKind::IntegrationInstall, + outcome, + ); + } + outcome.repaint = true; + } + + pub(super) fn handle_settings_endpoint_result( + &mut self, + kind: PendingEndpointKind, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + ) -> (bool, Vec<ClientShellAction>) { + match kind { + PendingEndpointKind::IntegrationList => { + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.loading_integrations = false; + match result { + Ok(crate::api::schema::ResponseResult::IntegrationList { + integrations, + }) => { + settings.integrations = integrations; + settings.selected = settings + .selected + .min(settings.integrations.len().saturating_sub(1)); + } + Ok(_) => { + self.endpoint_error = Some( + "endpoint returned an unexpected integration list result".into(), + ); + } + Err(error) => self.endpoint_error = Some(error.message), + } + } + (true, Vec::new()) + } + PendingEndpointKind::IntegrationInstall => { + self.pending_integration_installs = + self.pending_integration_installs.saturating_sub(1); + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + match result { + Ok(crate::api::schema::ResponseResult::IntegrationInstall { + details, + .. + }) => settings.integration_messages.extend(details.messages), + Ok(_) => settings + .integration_messages + .push("endpoint returned an unexpected integration result".into()), + Err(error) => settings.integration_messages.push(error.message), + } + settings.installing_integrations = self.pending_integration_installs > 0; + } + let actions = if self.pending_integration_installs == 0 + && matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) + { + let mut deferred = ClientShellInput::default(); + self.queue_integration_list(&mut deferred, false); + deferred.actions + } else { + Vec::new() + }; + (true, actions) + } + _ => (false, Vec::new()), + } + } + + pub(super) fn route_settings_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> bool { + if !matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) { + return false; + } + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + if code == KeyCode::Esc { + if !matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + installing_integrations: true, + .. + })) + ) { + self.cancel_settings_overlay(); + outcome.repaint = true; + } + return true; + } + if matches!(code, KeyCode::Tab | KeyCode::Right | KeyCode::Char('l')) + && modifiers.is_empty() + { + self.move_settings_section(1, outcome); + return true; + } + if matches!(code, KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h')) + && modifiers.difference(KeyModifiers::SHIFT).is_empty() + { + self.move_settings_section(-1, outcome); + return true; + } + if matches!(code, KeyCode::Up | KeyCode::Char('k')) && modifiers.is_empty() { + self.move_settings_selection(-1); + outcome.repaint = true; + return true; + } + if matches!(code, KeyCode::Down | KeyCode::Char('j')) && modifiers.is_empty() { + self.move_settings_selection(1); + outcome.repaint = true; + return true; + } + if matches!(code, KeyCode::Enter | KeyCode::Char(' ')) && modifiers.is_empty() { + self.apply_settings_choice(outcome); + return true; + } + true + } +} diff --git a/src/client/shell/settings_overlay.rs b/src/client/shell/settings_overlay.rs new file mode 100644 index 00000000..ea59ad02 --- /dev/null +++ b/src/client/shell/settings_overlay.rs @@ -0,0 +1,414 @@ +use super::*; + +fn choice_style(selected: bool, palette: &Palette) -> Style { + if selected { + Style::default() + .fg(contrast(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.panel_bg) + } +} + +fn draw_choice( + buffer: &mut Buffer, + rect: Rect, + label: &str, + selected: bool, + current: bool, + palette: &Palette, +) { + let style = choice_style(selected, palette); + buffer.set_style(rect, style); + let marker = if selected { "▸" } else { " " }; + let current = if current { " ✓" } else { "" }; + put_text( + buffer, + rect.x, + rect.y, + rect.width, + &format!(" {marker} {label}{current}"), + style, + ); +} + +pub(super) fn render_settings_overlay( + buffer: &mut Buffer, + settings: &ClientSettingsOverlay, + integration_updates_available: bool, + palette: &Palette, +) -> Option<OverlayRender> { + let integration_height = 14u16 + .saturating_add(settings.integrations.len().max(1) as u16) + .saturating_add(settings.integration_messages.len().min(6) as u16); + let height = if settings.section == ClientSettingsSection::Integrations { + integration_height.max(22) + } else { + 22 + }; + let popup = popup(buffer.area, 76, height)?; + let inner = panel(buffer, popup, palette.accent, palette.panel_bg)?; + if inner.width < 20 || inner.height < 8 { + return None; + } + + put_text( + buffer, + inner.x, + inner.y, + inner.width, + " settings", + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + + let integration_badge = integration_updates_available + || settings + .integrations + .iter() + .any(|integration| integration.state == crate::api::schema::IntegrationState::Outdated); + let mut tab_x = inner.x; + let mut tab_hits = Vec::new(); + for section in ClientSettingsSection::ALL { + let badge = *section == ClientSettingsSection::Integrations && integration_badge; + let label = if badge { + format!(" ● {} ", section.label()) + } else { + format!(" {} ", section.label()) + }; + let width = display_width(&label).min(inner.right().saturating_sub(tab_x)); + let rect = Rect::new(tab_x, inner.y + 1, width, 1); + let active = *section == settings.section; + let style = if active { + Style::default() + .fg(contrast(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.overlay1).bg(palette.panel_bg) + }; + buffer.set_style(rect, style); + put_text(buffer, rect.x, rect.y, rect.width, &label, style); + if badge && !active { + put_text( + buffer, + rect.x.saturating_add(1), + rect.y, + rect.width.saturating_sub(1).min(2), + "● ", + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + } + tab_hits.push((rect, *section)); + tab_x = tab_x.saturating_add(width.saturating_add(1)); + if tab_x >= inner.right() { + break; + } + } + put_text( + buffer, + inner.x, + inner.y + 2, + inner.width, + &"─".repeat(inner.width as usize), + Style::default().fg(palette.surface0).bg(palette.panel_bg), + ); + + let content = Rect::new( + inner.x, + inner.y + 4, + inner.width, + inner.height.saturating_sub(7), + ); + let mut choice_hits = Vec::new(); + match settings.section { + ClientSettingsSection::Theme => { + let visible = usize::from(content.height); + let scroll = settings.selected.saturating_sub(visible.saturating_sub(1)); + for (visible_index, (index, name)) in crate::config::THEME_NAMES + .iter() + .enumerate() + .skip(scroll) + .take(visible) + .enumerate() + { + let rect = Rect::new( + content.x, + content.y + visible_index as u16, + content.width, + 1, + ); + draw_choice( + buffer, + rect, + name, + index == settings.selected, + super::super::settings::normalized_theme_name(name) + == super::super::settings::normalized_theme_name( + &settings.original_theme_name, + ), + palette, + ); + choice_hits.push((rect, index)); + } + } + ClientSettingsSection::Indicators => { + render_choice_section( + buffer, + content, + "agent status indicators", + "choose color dots or distinct symbols for each state", + &["color dots ● ● ● ○ ·", "distinct symbols × ◐ ✓ ○ ·"], + settings.selected, + palette, + &mut choice_hits, + ); + } + ClientSettingsSection::Sound => { + render_choice_section( + buffer, + content, + "sound alerts", + "play sounds when agents change state in background", + &["on", "off"], + settings.selected, + palette, + &mut choice_hits, + ); + } + ClientSettingsSection::Toast => { + render_choice_section( + buffer, + content, + "notification popups", + "choose where background popup notifications should appear", + &["off", "inside herdr", "via terminal", "via system"], + settings.selected, + palette, + &mut choice_hits, + ); + } + ClientSettingsSection::Integrations => { + render_integrations(buffer, content, settings, palette); + } + } + + let installable = settings + .integrations + .iter() + .any(super::super::settings::integration_needs_install); + let show_primary = settings.section != ClientSettingsSection::Integrations || installable; + let labels = if show_primary { vec![10, 12] } else { vec![12] }; + let buttons = row(inner, &labels, 2, inner.height.saturating_sub(1)); + let (primary, close) = if show_primary { + let primary = buttons[0]; + button( + buffer, + primary, + if settings.section == ClientSettingsSection::Integrations { + " ↵ install " + } else { + " ↵ apply " + }, + Style::default() + .fg(contrast(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD), + ); + (primary, buttons[1]) + } else { + (Rect::default(), buttons[0]) + }; + button( + buffer, + close, + " esc close ", + Style::default() + .fg(palette.text) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + inner.x, + inner.bottom().saturating_sub(2), + inner.width, + " ↑↓ select tab section", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + + Some(OverlayRender { + primary, + cancel: close, + settings_popup: popup, + settings_tabs: tab_hits, + settings_choices: choice_hits, + ..OverlayRender::default() + }) +} + +fn render_choice_section( + buffer: &mut Buffer, + area: Rect, + title: &str, + description: &str, + choices: &[&str], + selected: usize, + palette: &Palette, + hits: &mut Vec<(Rect, usize)>, +) { + put_text( + buffer, + area.x, + area.y, + area.width, + title, + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + area.x, + area.y + 1, + area.width, + description, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + let row_gap = u16::from(choices.len() > 2); + for (index, choice) in choices.iter().enumerate() { + let y = area.y + 3 + index as u16 * (1 + row_gap); + if y >= area.bottom() { + break; + } + let rect = Rect::new(area.x, y, area.width, 1); + draw_choice(buffer, rect, choice, index == selected, false, palette); + hits.push((rect, index)); + } +} + +fn render_integrations( + buffer: &mut Buffer, + area: Rect, + settings: &ClientSettingsOverlay, + palette: &Palette, +) { + put_text( + buffer, + area.x, + area.y, + area.width, + "agent integrations", + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + area.x, + area.y + 1, + area.width, + "let agents report state directly instead of relying only on process detection", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + if settings.loading_integrations { + put_text( + buffer, + area.x, + area.y + 3, + area.width, + " loading integrations…", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + return; + } + if settings.integrations.is_empty() { + put_text( + buffer, + area.x, + area.y + 3, + area.width, + " no integration targets available", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + return; + } + for (index, integration) in settings.integrations.iter().enumerate() { + let y = area.y + 3 + index as u16; + if y >= area.bottom() { + break; + } + let (marker, color, status) = match integration.state { + crate::api::schema::IntegrationState::Current => ("✓", palette.green, "installed"), + crate::api::schema::IntegrationState::Outdated => { + ("↻", palette.yellow, "update available") + } + crate::api::schema::IntegrationState::NotInstalled if integration.available => { + ("+", palette.accent, "available") + } + crate::api::schema::IntegrationState::NotInstalled => { + ("–", palette.overlay0, "not found") + } + }; + put_text( + buffer, + area.x, + y, + 3, + &format!(" {marker}"), + Style::default().fg(color).bg(palette.panel_bg), + ); + put_text( + buffer, + area.x + 3, + y, + 11.min(area.width.saturating_sub(3)), + &format!("{:<9}", integration.label), + Style::default().fg(palette.subtext0).bg(palette.panel_bg), + ); + put_text( + buffer, + area.x + 14, + y, + area.width.saturating_sub(14), + status, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } + let message_y = area + .y + .saturating_add(4) + .saturating_add(settings.integrations.len() as u16); + for (offset, message) in settings.integration_messages.iter().take(6).enumerate() { + let y = message_y.saturating_add(offset as u16); + if y >= area.bottom() { + break; + } + put_text( + buffer, + area.x, + y, + area.width, + &format!(" {message}"), + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } + if settings.installing_integrations && message_y < area.bottom() { + put_text( + buffer, + area.x, + message_y, + area.width, + " installing…", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } +} diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs new file mode 100644 index 00000000..f8a87043 --- /dev/null +++ b/src/client/shell/sidebar.rs @@ -0,0 +1,698 @@ +use super::*; +use ratatui::{ + text::Line, + widgets::{Paragraph, Widget}, +}; + +fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) { + let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); + if content.is_empty() { + return (Rect::default(), None, Rect::default()); + } + if content.height < 7 { + return (content, None, Rect::default()); + } + let workspace_height = content.height.div_ceil(2); + let divider_y = content.y + workspace_height; + let detail_height = content.height.saturating_sub(workspace_height + 1); + ( + Rect::new(content.x, content.y, content.width, workspace_height), + Some(divider_y), + Rect::new(content.x, divider_y + 1, content.width, detail_height), + ) +} + +pub(crate) fn render_collapsed_sidebar( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + selected_workspace_id: Option<&str>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + render_sidebar_background(buffer, area, palette); + let (workspace_area, divider_y, detail_area) = collapsed_sidebar_sections(area); + for (index, workspace) in snapshot + .workspaces + .iter() + .take(workspace_area.height as usize) + .enumerate() + { + let rect = Rect::new( + workspace_area.x, + workspace_area.y + index as u16, + workspace_area.width, + 1, + ); + let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); + let selection_background = + if workspace.focused && palette.selection_bg == ratatui::style::Color::Reset { + palette.active_row_bg + } else { + palette.selection_bg + }; + if selected { + buffer.set_style(rect, Style::default().bg(selection_background)); + } else if workspace.focused { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + let number_style = if selected { + Style::default() + .fg(palette.overlay1) + .bg(selection_background) + } else if workspace.focused { + Style::default().fg(palette.text).bg(palette.active_row_bg) + } else { + Style::default().fg(palette.overlay0) + }; + put_text( + buffer, + rect.x, + rect.y, + rect.width.min(2), + &format!("{:<2}", index + 1), + number_style, + ); + let status = workspace.agent_status; + put_text( + buffer, + rect.x.saturating_add(2), + rect.y, + rect.width.saturating_sub(2), + status_icon(status, config.status_indicators), + Style::default().fg(status_color(status, palette)), + ); + hits.workspaces.push(WorkspaceHit { + rect, + workspace_id: workspace.workspace_id.clone(), + indented: false, + group_toggle: None, + }); + } + + if let Some(divider_y) = divider_y { + put_text( + buffer, + workspace_area.x, + divider_y, + workspace_area.width, + &"─".repeat(workspace_area.width as usize), + Style::default().fg(palette.surface_dim), + ); + } + + let detail_content = Rect::new( + detail_area.x, + detail_area.y, + detail_area.width, + detail_area.height.saturating_sub(1), + ); + for (index, pane_id) in super::ordered_agent_pane_ids(snapshot, config.agent_panel_sort) + .into_iter() + .take(detail_content.height as usize) + .enumerate() + { + let Some(agent) = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + else { + continue; + }; + let rect = Rect::new( + detail_content.x, + detail_content.y + index as u16, + detail_content.width, + 1, + ); + if agent.focused { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + put_text( + buffer, + rect.x, + rect.y, + rect.width.min(2), + &format!("{:<2}", index + 1), + Style::default().fg(if agent.focused { + palette.text + } else { + palette.overlay0 + }), + ); + put_text( + buffer, + rect.x.saturating_add(2), + rect.y, + rect.width.saturating_sub(2), + status_icon(agent.agent_status, config.status_indicators), + Style::default().fg(status_color(agent.agent_status, palette)), + ); + hits.agents.push((rect, pane_id)); + } + hits.sidebar_toggle = if area.is_empty() || workspace_area.width == 0 { + Rect::default() + } else { + Rect::new( + workspace_area.x + workspace_area.width / 2, + area.bottom().saturating_sub(1), + 1, + 1, + ) + }; + put_text( + buffer, + hits.sidebar_toggle.x, + hits.sidebar_toggle.y, + hits.sidebar_toggle.width, + "»", + if super::super::global_menu::global_menu_attention(snapshot) { + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.overlay0) + }, + ); +} + +pub(crate) fn render_sidebar( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + state: &mut ShellRenderState<'_>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + render_sidebar_background(buffer, area, palette); + hits.sidebar_divider = if area.is_empty() { + Rect::default() + } else { + Rect::new(area.right().saturating_sub(1), area.y, 1, area.height) + }; + let (workspace_area, detail_area) = + crate::ui::expanded_sidebar_sections(area, state.sidebar_section_split); + hits.sidebar_section_divider = + crate::ui::sidebar_section_divider_rect(area, state.sidebar_section_split); + put_text( + buffer, + workspace_area.x, + workspace_area.y, + workspace_area.width, + " spaces", + Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::BOLD), + ); + + let entries = workspace_entries(snapshot, state.collapsed_groups); + let body = Rect::new( + workspace_area.x, + workspace_area.y.saturating_add(WORKSPACE_HEADER_ROWS), + workspace_area.width, + workspace_area + .height + .saturating_sub(WORKSPACE_HEADER_ROWS + 1), + ); + hits.workspace_body = body; + let row_heights = entries + .iter() + .map(|entry| { + snapshot + .workspaces + .get(entry.index) + .map(|workspace| { + workspace_rows( + workspace, + displayed_workspace_status(snapshot, workspace, state.collapsed_groups), + entry.indented, + &config.spaces, + ) + .len() + .max(1) + .min(u16::MAX as usize) as u16 + }) + .unwrap_or(1) + }) + .collect::<Vec<_>>(); + let gaps = entries + .iter() + .enumerate() + .map(|(index, _)| { + entries + .get(index + 1) + .map_or(0, |next| u16::from(!next.indented) * config.spaces.row_gap) + }) + .collect::<Vec<_>>(); + let metrics = super::scroll::list_scroll_metrics( + &row_heights, + &gaps, + body.height, + *state.workspace_scroll, + ); + hits.workspace_max_scroll = metrics.max_offset_from_bottom; + hits.workspace_scroll_metrics = Some(metrics); + *state.workspace_scroll = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom); + let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1; + let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); + let mut y = body.y; + for (entry_position, entry) in entries.iter().enumerate().skip(*state.workspace_scroll) { + let Some(workspace) = snapshot.workspaces.get(entry.index) else { + continue; + }; + let status = displayed_workspace_status(snapshot, workspace, state.collapsed_groups); + let rows = workspace_rows(workspace, status, entry.indented, &config.spaces); + let row_height = (rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + if y.saturating_add(row_height) > body.bottom() { + break; + } + let rect = Rect::new(body.x, y, content_width, row_height); + let selected = state.selected_workspace_id == Some(workspace.workspace_id.as_str()); + let dragged = state.dragged_workspace_id == Some(workspace.workspace_id.as_str()); + if selected { + buffer.set_style(rect, Style::default().bg(palette.selection_bg)); + } else if dragged { + buffer.set_style(rect, Style::default().bg(palette.surface1)); + } else if workspace.focused { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + render_workspace_rows( + buffer, + rect, + workspace, + status, + config.status_indicators, + entry, + rows, + selected, + dragged, + palette, + ); + let group_toggle = parent_group_key(snapshot, entry.index).map(|key| { + let rect = Rect::new(rect.right().saturating_sub(1), rect.y, 1, 1); + put_text( + buffer, + rect.x, + rect.y, + rect.width, + if state.collapsed_groups.contains(&key) { + "▸" + } else { + "▾" + }, + Style::default().fg(palette.accent), + ); + (rect, key) + }); + hits.workspaces.push(WorkspaceHit { + rect, + workspace_id: workspace.workspace_id.clone(), + indented: entry.indented, + group_toggle, + }); + let gap = entries + .get(entry_position + 1) + .map_or(0, |next| u16::from(!next.indented) * config.spaces.row_gap); + y = y.saturating_add(row_height + gap); + } + + if show_scrollbar { + let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height); + hits.workspace_scrollbar = track; + super::scroll::render_list_scrollbar(buffer, track, metrics, palette); + } + + if let Some(row) = state.workspace_drop_indicator_row.filter(|row| { + *row >= workspace_area.y.saturating_add(1) + && *row < workspace_area.bottom().saturating_sub(1) + }) { + put_text( + buffer, + body.x, + row, + body.width, + &"─".repeat(body.width as usize), + Style::default().fg(palette.accent), + ); + } + + let footer_y = workspace_area.bottom().saturating_sub(1); + if config.mouse_capture { + hits.new_workspace = Rect::new( + workspace_area.x, + footer_y, + 5.min(workspace_area.width), + u16::from(workspace_area.height > 0), + ); + put_text( + buffer, + workspace_area.x, + footer_y, + workspace_area.width, + " new", + Style::default().fg(palette.overlay0), + ); + let attention = super::super::global_menu::global_menu_attention(snapshot); + let launcher_width = if attention { 8 } else { 6 }.min(workspace_area.width); + hits.global_launcher = Rect::new( + workspace_area.right().saturating_sub(launcher_width), + footer_y, + launcher_width, + 1, + ); + if attention { + let start_x = workspace_area.right().saturating_sub(6); + put_text( + buffer, + start_x, + footer_y, + 2, + "● ", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + start_x.saturating_add(2), + footer_y, + 4, + "menu", + Style::default().fg(palette.overlay0), + ); + } else { + put_right_text( + buffer, + workspace_area, + footer_y, + "menu", + Style::default().fg(palette.overlay0), + ); + } + } + + super::render_agent_panel( + buffer, + detail_area, + snapshot, + config, + state.agent_scroll, + hits, + ); + + hits.sidebar_toggle = Rect::new( + area.right().saturating_sub(2), + area.bottom().saturating_sub(1), + u16::from(area.width > 1), + u16::from(area.height > 0), + ); + put_text( + buffer, + hits.sidebar_toggle.x, + hits.sidebar_toggle.y, + hits.sidebar_toggle.width, + "«", + Style::default().fg(palette.overlay0), + ); +} + +pub(crate) fn render_sidebar_background(buffer: &mut Buffer, area: Rect, palette: &Palette) { + buffer.set_style(area, Style::default().bg(palette.sidebar_bg)); + let separator_x = area.right().saturating_sub(1); + for y in area.y..area.bottom() { + if let Some(cell) = buffer.cell_mut((separator_x, y)) { + cell.set_symbol("│"); + cell.set_style(Style::default().fg(palette.surface_dim)); + } + } +} + +pub(crate) fn workspace_entries( + snapshot: &ClientShellSnapshot, + collapsed_groups: &HashSet<String>, +) -> Vec<WorkspaceEntry> { + let mut members = HashMap::<&str, Vec<usize>>::new(); + for (index, workspace) in snapshot.workspaces.iter().enumerate() { + if let Some(worktree) = &workspace.worktree { + members.entry(&worktree.key).or_default().push(index); + } + } + let grouped = members + .iter() + .filter(|(_, indices)| { + indices.len() >= 2 + && indices.iter().any(|index| { + snapshot.workspaces[*index] + .worktree + .as_ref() + .is_some_and(|worktree| !worktree.is_linked_worktree) + }) + }) + .map(|(key, _)| *key) + .collect::<HashSet<_>>(); + let mut emitted = HashSet::<&str>::new(); + let mut entries = Vec::new(); + for (index, workspace) in snapshot.workspaces.iter().enumerate() { + let Some(worktree) = workspace + .worktree + .as_ref() + .filter(|worktree| grouped.contains(worktree.key.as_str())) + else { + entries.push(WorkspaceEntry { + index, + indented: false, + last_child: false, + }); + continue; + }; + if !emitted.insert(&worktree.key) { + continue; + } + let Some(group_members) = members.get(worktree.key.as_str()) else { + continue; + }; + let parent = group_members + .iter() + .copied() + .find(|member| { + snapshot.workspaces[*member] + .worktree + .as_ref() + .is_some_and(|worktree| !worktree.is_linked_worktree) + }) + .unwrap_or(index); + entries.push(WorkspaceEntry { + index: parent, + indented: false, + last_child: false, + }); + if collapsed_groups.contains(&worktree.key) { + if let Some(active) = group_members + .iter() + .copied() + .find(|member| *member != parent && snapshot.workspaces[*member].focused) + { + entries.push(WorkspaceEntry { + index: active, + indented: true, + last_child: true, + }); + } + continue; + } + let children = group_members + .iter() + .copied() + .filter(|member| *member != parent) + .collect::<Vec<_>>(); + for (child_index, child) in children.iter().enumerate() { + entries.push(WorkspaceEntry { + index: *child, + indented: true, + last_child: child_index + 1 == children.len(), + }); + } + } + entries +} + +fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> { + let workspace = snapshot.workspaces.get(index)?; + let worktree = workspace.worktree.as_ref()?; + if worktree.is_linked_worktree { + return None; + } + (snapshot + .workspaces + .iter() + .filter(|candidate| { + candidate + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .count() + >= 2) + .then(|| worktree.key.clone()) +} + +fn displayed_workspace_status( + snapshot: &ClientShellSnapshot, + workspace: &ClientShellWorkspace, + collapsed_groups: &HashSet<String>, +) -> crate::api::schema::AgentStatus { + let Some(worktree) = workspace + .worktree + .as_ref() + .filter(|worktree| !worktree.is_linked_worktree) + else { + return workspace.agent_status; + }; + if !collapsed_groups.contains(&worktree.key) { + return workspace.agent_status; + } + snapshot + .workspaces + .iter() + .filter(|candidate| { + candidate + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .map(|candidate| candidate.agent_status) + .max_by_key(|status| status_priority(*status)) + .unwrap_or(workspace.agent_status) +} + +fn workspace_rows( + workspace: &ClientShellWorkspace, + status: crate::api::schema::AgentStatus, + indented: bool, + config: &SpacesSidebarConfig, +) -> Vec<Vec<crate::ui::ResolvedToken>> { + let label = if indented && !workspace.custom_label { + workspace + .branch + .as_deref() + .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) + .unwrap_or(&workspace.label) + } else { + &workspace.label + }; + let token_values = workspace.tokens.iter().cloned().collect::<HashMap<_, _>>(); + crate::ui::sidebar_space_rows( + config, + crate::ui::SpaceTokenContext { + workspace: label, + branch: workspace.branch.as_deref(), + state_text: status_text(status), + ahead_behind: workspace.git_ahead_behind, + tokens: &token_values, + suppress_git_details: indented, + }, + ) +} + +fn render_workspace_rows( + buffer: &mut Buffer, + area: Rect, + workspace: &ClientShellWorkspace, + status: crate::api::schema::AgentStatus, + indicators: crate::config::StatusIndicatorStyle, + entry: &WorkspaceEntry, + rows: Vec<Vec<crate::ui::ResolvedToken>>, + selected: bool, + dragged: bool, + palette: &Palette, +) { + for (row_index, row) in rows.iter().enumerate() { + let y = area.y + row_index as u16; + if y >= area.bottom() { + break; + } + let mut x = area.x; + if entry.indented { + let prefix = if row_index == 0 { + if entry.last_child { + " └─ " + } else { + " ├─ " + } + } else if entry.last_child { + " " + } else { + " │ " + }; + x = put_segment( + buffer, + x, + y, + area.right(), + prefix, + Style::default().fg(palette.overlay0), + ); + } else if row_index == 0 { + x = x.saturating_add(1); + } else { + x = x.saturating_add(3); + } + let highlighted = workspace.focused || dragged; + let workspace_style = Style::default() + .fg(if highlighted { + palette.text + } else { + palette.subtext0 + }) + .add_modifier(if highlighted { + Modifier::BOLD + } else { + Modifier::empty() + }); + let secondary_style = Style::default().fg(if workspace.focused { + palette.mauve + } else { + palette.overlay0 + }); + let spans = crate::ui::resolved_token_spans( + row, + ( + status_icon(status, indicators), + Style::default().fg(status_color(status, palette)), + ), + Style::default() + .fg(status_color(status, palette)) + .add_modifier(Modifier::DIM), + workspace_style, + secondary_style, + Style::default().fg(palette.overlay1), + palette, + area.right().saturating_sub(2).saturating_sub(x) as usize, + ); + Paragraph::new(Line::from(spans)).render( + Rect::new(x, y, area.right().saturating_sub(2).saturating_sub(x), 1), + buffer, + ); + } + + let background = if selected { + Some(palette.selection_bg) + } else if dragged { + Some(palette.surface1) + } else if workspace.focused { + Some(palette.active_row_bg) + } else { + None + }; + if let Some(background) = background { + for y in area.y..area.bottom() { + for x in area.x..area.right() { + buffer[(x, y)].set_bg(background); + } + } + } +} diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs new file mode 100644 index 00000000..81173c18 --- /dev/null +++ b/src/client/shell/state.rs @@ -0,0 +1,1613 @@ +use super::*; + +pub(super) const MIN_TAB_WIDTH: u16 = 8; +pub(super) const NEW_TAB_WIDTH: u16 = 3; +pub(super) const WORKSPACE_HEADER_ROWS: u16 = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ClientShellKeybindingSource { + Local, + RemoteLocal, + Endpoint, +} + +pub(crate) struct ClientShellConfig { + pub(super) sidebar_width: u16, + pub(super) sidebar_min_width: u16, + pub(super) sidebar_max_width: u16, + pub(super) sidebar_start_collapsed: bool, + pub(super) sidebar_collapsed_mode: SidebarCollapsedModeConfig, + pub(super) mobile_width_threshold: u16, + pub(super) tab_bar_position: TabBarPositionConfig, + pub(super) hide_tab_bar_when_single_tab: bool, + pub(super) spaces: SpacesSidebarConfig, + pub(super) agents: crate::config::AgentsSidebarConfig, + pub(super) agent_panel_sort: crate::config::AgentPanelSortConfig, + pub(super) status_indicators: crate::config::StatusIndicatorStyle, + pub(super) sound_enabled: bool, + pub(super) toast_delivery: crate::config::ToastDelivery, + pub(super) toast_delay_seconds: u64, + pub(super) toast_position: crate::config::ToastHerdrPosition, + pub(super) copy_on_select: bool, + pub(super) clipboard_toast_enabled: bool, + pub(super) clipboard_toast_position: crate::config::ToastClipboardPosition, + pub(super) theme_name: String, + pub(super) theme_runtime: crate::app::state::ThemeRuntimeConfig, + pub(super) palette: Palette, + pub(super) keybinds: LiveKeybindConfig, + pub(super) local_keys: crate::config::KeysConfig, + pub(super) keybinding_source: ClientShellKeybindingSource, + pub(super) prompt_new_tab_name: bool, + pub(super) prompt_new_workspace_name: bool, + pub(super) confirm_close: bool, + pub(super) mouse_capture: bool, + pub(super) mouse_scroll_lines: usize, + pub(super) right_click_passthrough_modifiers: Option<crossterm::event::KeyModifiers>, + pub(super) redraw_on_focus_gained: bool, + pub(super) switch_ascii_input_source_in_prefix: bool, + pub(super) local_config_path: std::path::PathBuf, + pub(super) preferences_path: Option<std::path::PathBuf>, + pub(super) preferences: preferences::ClientChromePreferences, + pub(super) startup_config_diagnostic: Option<String>, + pub(super) startup_onboarding: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ClientShellLayout { + pub sidebar: Rect, + pub tab_bar: Rect, + pub mobile_header: Rect, + pub pane_surface: Rect, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ClientMobileTarget { + NewWorkspace, + Workspace(String), + NewTab, + Tab(String), + Agent(String), + Menu(usize), +} + +#[derive(Default)] +pub(super) struct ShellHitMap { + pub(super) workspaces: Vec<WorkspaceHit>, + pub(super) workspace_body: Rect, + pub(super) workspace_scrollbar: Rect, + pub(super) workspace_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(super) workspace_max_scroll: usize, + pub(super) tabs: Vec<(Rect, String)>, + pub(super) panes: Vec<PaneHit>, + pub(super) popup: Option<PaneHit>, + pub(super) pane_splits: Vec<PaneSplitHit>, + pub(super) agents: Vec<(Rect, String)>, + pub(super) agent_body: Rect, + pub(super) agent_scrollbar: Rect, + pub(super) agent_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(super) agent_max_scroll: usize, + pub(super) agent_sort_toggle: Rect, + pub(super) sidebar_divider: Rect, + pub(super) sidebar_section_divider: Rect, + pub(super) sidebar_toggle: Rect, + pub(super) new_workspace: Rect, + pub(super) new_tab: Rect, + pub(super) tab_scroll_left: Rect, + pub(super) tab_scroll_right: Rect, + pub(super) mobile_switch: Rect, + pub(super) mobile_close: Rect, + pub(super) mobile_targets: Vec<(Rect, ClientMobileTarget)>, + pub(super) mobile_max_scroll: usize, + pub(super) global_launcher: Rect, + pub(super) notification_toast: Rect, + pub(super) global_menu_rows: Vec<(Rect, usize)>, + pub(super) context_menu_rows: Vec<(Rect, usize)>, + pub(super) overlay_primary: Rect, + pub(super) overlay_clear: Rect, + pub(super) overlay_cancel: Rect, + pub(super) navigator_popup: Rect, + pub(super) navigator_search: Rect, + pub(super) navigator_rows: Vec<(Rect, usize)>, + pub(super) worktree_search: Rect, + pub(super) worktree_rows: Vec<(Rect, usize)>, + pub(super) help_popup: Rect, + pub(super) help_scrollbar: Rect, + pub(super) help_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(super) help_max_scroll: usize, + pub(super) settings_popup: Rect, + pub(super) settings_tabs: Vec<(Rect, ClientSettingsSection)>, + pub(super) settings_choices: Vec<(Rect, usize)>, + pub(super) product_announcement_scrollbar: Rect, + pub(super) product_announcement_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(super) product_announcement_max_scroll: usize, + pub(super) release_notes_scrollbar: Rect, + pub(super) release_notes_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(super) release_notes_max_scroll: usize, +} + +#[derive(Clone)] +pub(super) struct PaneHit { + pub(super) rect: Rect, + pub(super) inner_rect: Rect, + pub(super) scrollbar_rect: Option<Rect>, + pub(super) scroll: Option<crate::pane::ScrollMetrics>, + pub(super) pane_id: String, + pub(super) popup: bool, + pub(super) mouse_reporting: bool, + pub(super) sgr_pixel_mouse: bool, + pub(super) pixel_width: u32, + pub(super) pixel_height: u32, +} + +#[derive(Clone)] +pub(super) struct PaneSplitHit { + pub(super) direction: crate::protocol::PaneSurfaceSplitDirection, + pub(super) pos: u16, + pub(super) area: Rect, + pub(super) hit_rect: Rect, + pub(super) path: Vec<bool>, + pub(super) topology_signature: u64, +} + +pub(super) struct ClientPaneMouseGesture { + pub(super) hit: PaneHit, + pub(super) button: crossterm::event::MouseButton, + pub(super) stripped_modifiers: crossterm::event::KeyModifiers, + pub(super) last_event: crossterm::event::MouseEvent, + pub(super) last_position: crate::protocol::ClientMousePosition, +} + +pub(super) struct ClientWorkspacePress { + pub(super) workspace_id: String, + pub(super) start_column: u16, + pub(super) start_row: u16, +} + +pub(super) struct ClientTabPress { + pub(super) tab_id: String, + pub(super) workspace_id: String, + pub(super) start_column: u16, + pub(super) start_row: u16, +} + +pub(super) enum ClientChromeDrag { + SidebarWidth, + SidebarSection, + WorkspaceScrollbar { + grab_row_offset: u16, + }, + AgentScrollbar { + grab_row_offset: u16, + }, + HelpScrollbar { + grab_row_offset: u16, + }, + ProductAnnouncementScrollbar { + grab_row_offset: u16, + }, + ReleaseNotesScrollbar { + grab_row_offset: u16, + }, + Tab { + tab_id: String, + workspace_id: String, + insert_index: Option<usize>, + }, + Workspace { + source_workspace_id: String, + target: Option<(Option<String>, u16)>, + }, + PaneSplit { + hit: PaneSplitHit, + tab_id: String, + grab_offset: i32, + last_sent_ratio: Option<f32>, + last_sent_at: Option<std::time::Instant>, + }, + PaneScrollbar { + hit: PaneHit, + grab_row_offset: u16, + last_sent_offset: Option<usize>, + last_sent_at: Option<std::time::Instant>, + }, +} + +pub(super) struct WorkspaceHit { + pub(super) rect: Rect, + pub(super) workspace_id: String, + pub(super) indented: bool, + pub(super) group_toggle: Option<(Rect, String)>, +} + +#[derive(Debug)] +pub(crate) enum ClientShellAction { + Endpoint { + boot_id: String, + request: Box<crate::api::schema::Request>, + }, + ClipboardWrite(Vec<u8>), + Request(ClientMessage), + OpenSafeWebUrl(String), + ReplayMouse(Vec<crossterm::event::MouseEvent>), + Keybind(crate::input::KeybindAction), +} + +#[derive(Default)] +pub(crate) struct ClientShellInput { + pub detach: bool, + pub repaint: bool, + pub resize: bool, + pub query_host_appearance: bool, + pub query_host_theme: bool, + pub requests: Vec<ClientMessage>, + pub actions: Vec<ClientShellAction>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientShellMode { + Terminal, + Prefix, + Navigate, + Resize, + Copy, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientShellOverlayKind { + Onboarding, + ProductAnnouncement, + ReleaseNotes, + Rename, + ConfirmClose, + Help, + Navigator, + WorktreeCreate, + WorktreeOpen, + WorktreeRemove, + ContextMenu, + GlobalMenu, + Settings, +} + +#[derive(Debug)] +pub(super) enum ClientRenameTarget { + NewWorkspace { + source_workspace_id: Option<String>, + cwd: Option<String>, + suggested_name: String, + }, + Workspace { + workspace_id: String, + }, + NewTab { + workspace_id: String, + default_name: String, + }, + Tab { + tab_id: String, + auto_name: bool, + original_name: String, + }, + Pane { + pane_id: String, + }, +} + +#[derive(Debug)] +pub(super) struct ClientRenameOverlay { + pub(super) title: &'static str, + pub(super) input: String, + pub(super) replace_on_type: bool, + pub(super) target: ClientRenameTarget, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ClientNavigatorFilter { + Blocked, + Working, + Idle, + Done, +} + +#[derive(Clone, Debug)] +pub(super) enum ClientNavigatorTarget { + Workspace(String), + Tab(String), + Pane(String), +} + +#[derive(Clone, Debug)] +pub(super) struct ClientNavigatorRow { + pub(super) depth: u8, + pub(super) label: String, + pub(super) meta: String, + pub(super) status: crate::api::schema::AgentStatus, + pub(super) current: bool, + pub(super) target: ClientNavigatorTarget, +} + +#[derive(Debug)] +pub(super) struct ClientNavigatorOverlay { + pub(super) query: String, + pub(super) search_focused: bool, + pub(super) selected: usize, + pub(super) scroll: usize, + pub(super) filter: Option<ClientNavigatorFilter>, + pub(super) expanded_workspaces: HashSet<String>, +} + +#[derive(Debug)] +pub(super) struct ClientHelpOverlay { + pub(super) query: String, + pub(super) search_focused: bool, + pub(super) scroll: usize, +} + +#[derive(Debug)] +pub(super) struct ClientGlobalMenuOverlay { + pub(super) highlighted: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientSettingsSection { + Theme, + Indicators, + Sound, + Toast, + Integrations, +} + +impl ClientSettingsSection { + pub(super) const ALL: &[Self] = &[ + Self::Theme, + Self::Indicators, + Self::Sound, + Self::Toast, + Self::Integrations, + ]; + + pub(super) fn label(self) -> &'static str { + match self { + Self::Theme => "theme", + Self::Indicators => "indicators", + Self::Sound => "sound", + Self::Toast => "toasts", + Self::Integrations => "integrations", + } + } +} + +#[derive(Debug)] +pub(super) struct ClientSettingsOverlay { + pub(super) section: ClientSettingsSection, + pub(super) selected: usize, + pub(super) original_theme_name: String, + pub(super) original_palette: Palette, + pub(super) integrations: Vec<crate::api::schema::IntegrationInfo>, + pub(super) integration_messages: Vec<String>, + pub(super) loading_integrations: bool, + pub(super) installing_integrations: bool, +} + +#[derive(Debug)] +pub(super) struct ClientWorktreeCreateOverlay { + pub(super) source_workspace_id: String, + pub(super) repo_name: String, + pub(super) branch: String, + pub(super) checkout_path: String, + pub(super) replace_on_type: bool, + pub(super) error: Option<String>, + pub(super) creating: bool, +} + +#[derive(Debug, Clone)] +pub(super) struct ClientWorktreeOpenEntry { + pub(super) path: String, + pub(super) branch: Option<String>, + pub(super) is_linked_worktree: bool, + pub(super) is_detached: bool, + pub(super) open_workspace_id: Option<String>, + pub(super) label: String, +} + +impl ClientWorktreeOpenEntry { + pub(super) fn status_label(&self) -> &'static str { + if self.open_workspace_id.is_some() { + "open" + } else if self.branch.is_some() { + "" + } else if self.is_detached && self.is_linked_worktree { + "detached" + } else { + "root" + } + } + + pub(super) fn matches_query(&self, query: &str) -> bool { + let query = query.trim().to_lowercase(); + query.is_empty() + || format!( + "{} {} {} {}", + self.label, + self.branch.as_deref().unwrap_or_default(), + self.path, + self.status_label() + ) + .to_lowercase() + .contains(&query) + } +} + +#[derive(Debug)] +pub(super) struct ClientWorktreeOpenOverlay { + pub(super) source_workspace_id: String, + pub(super) entries: Vec<ClientWorktreeOpenEntry>, + pub(super) selected: usize, + pub(super) query: String, + pub(super) search_focused: bool, + pub(super) error: Option<String>, + pub(super) opening: bool, +} + +impl ClientWorktreeOpenOverlay { + pub(super) fn filtered_indices(&self) -> Vec<usize> { + self.entries + .iter() + .enumerate() + .filter_map(|(index, entry)| entry.matches_query(&self.query).then_some(index)) + .collect() + } + + pub(super) fn selected_entry_index(&self) -> Option<usize> { + let filtered = self.filtered_indices(); + filtered + .contains(&self.selected) + .then_some(self.selected) + .or_else(|| filtered.first().copied()) + } +} + +#[derive(Debug)] +pub(super) struct ClientWorktreeRemoveOverlay { + pub(super) workspace_id: String, + pub(super) path: String, + pub(super) error: Option<String>, + pub(super) removing: bool, + pub(super) force_confirmation: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientContextMenuAction { + Rename, + Close, + NewWorktree, + OpenWorktree, + RemoveWorktree, + ToggleGroup, + NewTab, + RenamePane, + ClearPaneName, + SwapWithFocusedPane, + SplitRight, + SplitDown, + Zoom, + ToggleRightClickPassthrough, + ClosePane, +} + +#[derive(Debug)] +pub(super) enum ClientContextMenuTarget { + Workspace { + workspace_id: String, + is_git: bool, + is_linked_worktree: bool, + has_worktree_children: bool, + collapsed: bool, + }, + Tab { + tab_id: String, + workspace_id: String, + }, + Pane { + pane_id: String, + workspace_id: String, + source_pane_id: Option<String>, + has_manual_label: bool, + right_click_passthrough: bool, + }, +} + +#[derive(Debug)] +pub(super) struct ClientContextMenuOverlay { + pub(super) target: ClientContextMenuTarget, + pub(super) x: u16, + pub(super) y: u16, + pub(super) highlighted: usize, +} + +pub(super) struct ClientContextMenuItem { + pub(super) label: &'static str, + pub(super) action: ClientContextMenuAction, +} + +#[derive(Debug)] +pub(super) struct ClientConfirmCloseOverlay { + pub(super) workspace_id: String, + pub(super) title: String, + pub(super) detail: String, +} + +#[derive(Debug)] +pub(super) enum ClientShellOverlay { + Onboarding, + ProductAnnouncement(crate::app::state::ProductAnnouncementState), + ReleaseNotes(crate::app::state::ReleaseNotesState), + Rename(ClientRenameOverlay), + ConfirmClose(ClientConfirmCloseOverlay), + Help(ClientHelpOverlay), + Navigator(ClientNavigatorOverlay), + WorktreeCreate(ClientWorktreeCreateOverlay), + WorktreeOpen(ClientWorktreeOpenOverlay), + WorktreeRemove(ClientWorktreeRemoveOverlay), + ContextMenu(ClientContextMenuOverlay), + GlobalMenu(ClientGlobalMenuOverlay), + Settings(ClientSettingsOverlay), +} + +impl ClientShellOverlay { + pub(super) fn kind(&self) -> ClientShellOverlayKind { + match self { + Self::Onboarding => ClientShellOverlayKind::Onboarding, + Self::ProductAnnouncement(_) => ClientShellOverlayKind::ProductAnnouncement, + Self::ReleaseNotes(_) => ClientShellOverlayKind::ReleaseNotes, + Self::Rename(_) => ClientShellOverlayKind::Rename, + Self::ConfirmClose(_) => ClientShellOverlayKind::ConfirmClose, + Self::Help(_) => ClientShellOverlayKind::Help, + Self::Navigator(_) => ClientShellOverlayKind::Navigator, + Self::WorktreeCreate(_) => ClientShellOverlayKind::WorktreeCreate, + Self::WorktreeOpen(_) => ClientShellOverlayKind::WorktreeOpen, + Self::WorktreeRemove(_) => ClientShellOverlayKind::WorktreeRemove, + Self::ContextMenu(_) => ClientShellOverlayKind::ContextMenu, + Self::GlobalMenu(_) => ClientShellOverlayKind::GlobalMenu, + Self::Settings(_) => ClientShellOverlayKind::Settings, + } + } +} + +#[derive(Debug)] +pub(super) enum PendingEndpointKind { + Generic, + ProductAnnouncementDismiss { + version: String, + id: String, + }, + ReleaseNotesDismiss, + PopupCommand, + ReloadConfig, + IntegrationList, + IntegrationInstall, + PrepareWorktreeCreate { + workspace_id: String, + }, + PrepareWorktreeOpen { + workspace_id: String, + }, + PrepareWorktreeRemove { + workspace_id: String, + }, + WorktreeCreate, + WorktreeOpen, + WorktreeRemove { + forced: bool, + }, + SelectionCopy { + fallback: Option<ClientMessage>, + }, + PaneScroll { + pane_id: String, + serial: u64, + }, + WordSelection { + pane_id: String, + absolute_row: u32, + col: u16, + generation: u64, + }, + PaneLinkActivate { + pane_id: String, + inner_rect: Rect, + fallback_events: Vec<crossterm::event::MouseEvent>, + }, + CopyMotion { + pane_id: String, + origin: crate::api::schema::PaneTextPoint, + session_generation: u64, + }, + CopySearch { + pane_id: String, + origin: crate::api::schema::PaneTextPoint, + query: String, + direction: crate::api::schema::PaneCopySearchDirection, + repeat: bool, + generation: u64, + session_generation: u64, + }, +} + +pub(super) struct PendingEndpointRequest { + pub(super) boot_id: String, + pub(super) confirmation_workspace_id: Option<String>, + pub(super) kind: PendingEndpointKind, +} + +pub(crate) struct ClientShellEndpointError { + pub code: Option<String>, + pub message: String, +} + +pub(crate) enum ClientShellNotificationEffect { + Sound { + sound: crate::sound::Sound, + agent: Option<String>, + }, + Terminal { + title: String, + body: Option<String>, + }, + System { + title: String, + body: Option<String>, + }, +} + +pub(super) struct ClientPendingNotification { + pub(super) event: SemanticNotification, + pub(super) deadline: std::time::Instant, + pub(super) validate_state: bool, +} + +pub(super) struct ClientVisibleNotification { + pub(super) event: SemanticNotification, + pub(super) deadline: std::time::Instant, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum ClientInputTarget { + Pane(String), + Popup(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ClientInputContext { + pub(super) mode: ClientShellMode, + pub(super) overlay: Option<ClientShellOverlayKind>, + pub(super) popup_terminal_id: Option<String>, + pub(super) popup_pending: bool, + pub(super) retained_selection: bool, +} + +type ClientInputLeases = crate::input::InputLeaseTable<u8, ClientInputContext, ClientInputTarget>; + +#[derive(Clone, Debug)] +pub(super) struct ClientPaneClick { + pub(super) pane_id: String, + pub(super) viewport_row: u16, + pub(super) col: u16, + pub(super) at: std::time::Instant, +} + +impl ClientPaneClick { + pub(super) fn is_double_click_for(&self, next: &Self) -> bool { + self.pane_id == next.pane_id + && next.at.duration_since(self.at) <= std::time::Duration::from_millis(350) + && self.viewport_row.abs_diff(next.viewport_row) <= 1 + && self.col.abs_diff(next.col) <= 1 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientSelectionAutoscrollDirection { + Up, + Down, +} + +#[derive(Clone, Debug)] +pub(super) struct ClientSelectionAutoscroll { + pub(super) pane_id: String, + pub(super) direction: ClientSelectionAutoscrollDirection, + pub(super) last_mouse_column: u16, + pub(super) last_mouse_row: u16, + pub(super) inner_rect: Rect, + pub(super) offset_from_bottom: usize, + pub(super) max_offset_from_bottom: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientCopySelection { + Character { + anchor: crate::api::schema::PaneTextPoint, + }, + Linewise { + anchor_row: u32, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ClientCopySearchPrompt { + pub(super) direction: crate::api::schema::PaneCopySearchDirection, + pub(super) query: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum ClientCopyOperation { + Motion(crate::api::schema::PaneCopyMotion), + Search { + query: String, + direction: crate::api::schema::PaneCopySearchDirection, + repeat: bool, + }, +} + +pub(super) struct ClientCopySearchResult { + pub(super) content_revision: u64, + pub(super) matches: Vec<crate::api::schema::PaneTextRange>, + pub(super) total: u64, + pub(super) current: Option<usize>, + pub(super) current_global: Option<u64>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ClientCopyModeState { + pub(super) pane_id: String, + pub(super) content_revision: u64, + pub(super) geometry: (u16, u16), + pub(super) cursor: crate::api::schema::PaneTextPoint, + pub(super) offset_from_bottom: usize, + pub(super) max_offset_from_bottom: usize, + pub(super) entry_offset_from_bottom: usize, + pub(super) selection: Option<ClientCopySelection>, + pub(super) search_prompt: Option<ClientCopySearchPrompt>, + pub(super) search_query: String, + pub(super) search_direction: Option<crate::api::schema::PaneCopySearchDirection>, + pub(super) search_matches: Vec<crate::api::schema::PaneTextRange>, + pub(super) search_total: u64, + pub(super) search_current: Option<usize>, + pub(super) search_current_global: Option<u64>, + pub(super) search_generation: u64, + pub(super) copy_after_search: bool, +} + +pub(crate) struct ClientShellState { + pub(super) config: ClientShellConfig, + pub(super) snapshot: Option<Box<ClientShellSnapshot>>, + pub(super) pane_surface: Option<PaneSurfaceFrame>, + pub(super) graphics: crate::kitty_graphics::surface::ClientState, + pub(super) graphics_cell_size: crate::kitty_graphics::HostCellSize, + pub(super) popup_terminal_id: Option<String>, + pub(super) sidebar_collapsed: bool, + pub(super) sidebar_collapsed_manual: bool, + pub(super) sidebar_width: u16, + pub(super) sidebar_width_manual: bool, + pub(super) sidebar_section_split: f32, + pub(super) sidebar_section_split_manual: bool, + pub(super) agent_panel_sort_manual: bool, + pub(super) last_sidebar_divider_click: Option<std::time::Instant>, + pub(super) chrome_drag: Option<ClientChromeDrag>, + pub(super) workspace_press: Option<ClientWorkspacePress>, + pub(super) tab_press: Option<ClientTabPress>, + pub(super) collapsed_groups: HashSet<String>, + pub(super) workspace_scroll: usize, + pub(super) agent_scroll: usize, + pub(super) tab_scroll: usize, + pub(super) mobile_switcher_scroll: usize, + pub(super) reveal_mobile_workspace: bool, + pub(super) mobile_switcher_suspended: bool, + pub(super) reveal_focused_tab: bool, + pub(super) last_tab_bar_width: Option<u16>, + pub(super) last_composed_size: Option<(u16, u16)>, + pub(super) hits: ShellHitMap, + pub(super) mode: ClientShellMode, + pub(super) navigate_workspace_id: Option<String>, + pub(super) overlay: Option<ClientShellOverlay>, + pub(super) previous_pane_id: Option<String>, + pub(super) pane_mouse_gesture: Option<ClientPaneMouseGesture>, + pub(super) url_click_consumes_until_up: bool, + pub(super) replaying_url_click: bool, + pub(super) selection: Option<crate::selection::Selection<String>>, + pub(super) last_pane_click: Option<ClientPaneClick>, + pub(super) selection_autoscroll: Option<ClientSelectionAutoscroll>, + pub(super) selection_autoscroll_deadline: Option<std::time::Instant>, + pub(super) selection_highlight_clear_deadline: Option<std::time::Instant>, + pub(super) pending_word_selection: Option<u64>, + pub(super) word_selection_generation: u64, + pub(super) copy_mode: Option<ClientCopyModeState>, + pub(super) copy_session_generation: u64, + pub(super) copy_operation_in_flight: bool, + pub(super) copy_operation_queue: VecDeque<ClientCopyOperation>, + pub(super) copy_input_queue: VecDeque<crate::input::TerminalKey>, + pub(super) next_scroll_serial: u64, + pub(super) pane_scroll_in_flight: HashMap<String, u64>, + pub(super) pane_scroll_queued: HashMap<String, usize>, + pub(super) pane_scroll_targets: HashMap<String, usize>, + pub(super) copy_feedback: Option<crate::app::state::CopyFeedback>, + pub(super) copy_feedback_deadline: Option<std::time::Instant>, + pub(super) host_mouse_pixels: Option<crate::input::mouse::HostPixels>, + pub(super) input_leases: ClientInputLeases, + pub(super) popup_pending: bool, + pub(super) popup_pending_deadline: Option<std::time::Instant>, + pub(super) next_request_id: u64, + pub(super) pending_requests: HashMap<String, PendingEndpointRequest>, + pub(super) pending_integration_installs: usize, + pub(super) pending_notifications: Vec<ClientPendingNotification>, + pub(super) visible_notification: Option<ClientVisibleNotification>, + pub(super) outer_focused: Option<bool>, + pub(super) ascii_input_source_active: bool, + pub(super) pending_input_source_changes: Vec<bool>, + pub(super) host_appearance: Option<crate::terminal_theme::HostAppearance>, + pub(super) host_appearance_explicit: bool, + pub(super) local_config_diagnostic: Option<String>, + pub(super) config_diagnostic: Option<String>, + pub(super) endpoint_error: Option<String>, + pub(super) dismissed_product_announcement: Option<(String, String)>, +} + +pub(super) fn product_announcement_state( + announcement: &crate::protocol::ClientShellProductAnnouncement, +) -> crate::app::state::ProductAnnouncementState { + crate::app::state::ProductAnnouncementState { + version: announcement.version.clone(), + id: announcement.id.clone(), + title: announcement.title.clone(), + body: announcement.body.clone(), + scroll: 0, + preview: announcement.preview, + } +} + +pub(super) fn release_notes_state( + notes: &crate::protocol::ClientShellReleaseNotes, +) -> crate::app::state::ReleaseNotesState { + crate::app::state::ReleaseNotesState { + version: notes.version.clone(), + body: notes.body.clone(), + scroll: 0, + preview: notes.preview, + } +} + +#[derive(Clone, Copy)] +pub(super) struct WorkspaceEntry { + pub(super) index: usize, + pub(super) indented: bool, + pub(super) last_child: bool, +} + +impl ClientShellState { + pub(crate) fn new(mut config: ClientShellConfig) -> Self { + let preferences = config.preferences.clone(); + let local_config_diagnostic = config.startup_config_diagnostic.take(); + let overlay = config + .startup_onboarding + .then_some(ClientShellOverlay::Onboarding); + let sidebar_collapsed = preferences + .sidebar_collapsed + .unwrap_or(config.sidebar_start_collapsed); + let (min_width, max_width) = crate::config::validated_sidebar_bounds( + config.sidebar_min_width, + config.sidebar_max_width, + ) + .unwrap_or((18, 36)); + let sidebar_width = preferences + .sidebar_width + .unwrap_or(config.sidebar_width) + .clamp(min_width, max_width); + let sidebar_section_split = preferences + .sidebar_section_split + .filter(|split| split.is_finite()) + .map(|split| split.clamp(0.1, 0.9)) + .unwrap_or(0.5); + if let Some(sort) = preferences.agent_panel_sort { + config.agent_panel_sort = sort; + } + Self { + config, + snapshot: None, + pane_surface: None, + graphics: crate::kitty_graphics::surface::ClientState::default(), + graphics_cell_size: crate::kitty_graphics::HostCellSize { + width_px: 1, + height_px: 1, + }, + popup_terminal_id: None, + sidebar_collapsed, + sidebar_collapsed_manual: preferences.sidebar_collapsed.is_some(), + sidebar_width, + sidebar_width_manual: preferences.sidebar_width.is_some(), + sidebar_section_split, + sidebar_section_split_manual: preferences.sidebar_section_split.is_some(), + agent_panel_sort_manual: preferences.agent_panel_sort.is_some(), + last_sidebar_divider_click: None, + chrome_drag: None, + workspace_press: None, + tab_press: None, + collapsed_groups: preferences.collapsed_groups.into_iter().collect(), + workspace_scroll: 0, + agent_scroll: 0, + tab_scroll: 0, + mobile_switcher_scroll: 0, + reveal_mobile_workspace: false, + mobile_switcher_suspended: false, + reveal_focused_tab: true, + last_tab_bar_width: None, + last_composed_size: None, + hits: ShellHitMap::default(), + mode: ClientShellMode::Terminal, + navigate_workspace_id: None, + overlay, + previous_pane_id: None, + pane_mouse_gesture: None, + url_click_consumes_until_up: false, + replaying_url_click: false, + selection: None, + last_pane_click: None, + selection_autoscroll: None, + selection_autoscroll_deadline: None, + selection_highlight_clear_deadline: None, + pending_word_selection: None, + word_selection_generation: 0, + copy_mode: None, + copy_session_generation: 0, + copy_operation_in_flight: false, + copy_operation_queue: VecDeque::new(), + copy_input_queue: VecDeque::new(), + next_scroll_serial: 0, + pane_scroll_in_flight: HashMap::new(), + pane_scroll_queued: HashMap::new(), + pane_scroll_targets: HashMap::new(), + copy_feedback: None, + copy_feedback_deadline: None, + host_mouse_pixels: None, + input_leases: ClientInputLeases::default(), + popup_pending: false, + popup_pending_deadline: None, + next_request_id: 1, + pending_requests: HashMap::new(), + pending_integration_installs: 0, + pending_notifications: Vec::new(), + visible_notification: None, + outer_focused: None, + ascii_input_source_active: false, + pending_input_source_changes: Vec::new(), + host_appearance: None, + host_appearance_explicit: false, + config_diagnostic: local_config_diagnostic.clone(), + local_config_diagnostic, + endpoint_error: None, + dismissed_product_announcement: None, + } + } + + fn focused_tab_count(&self) -> usize { + let Some(snapshot) = self.snapshot.as_deref() else { + return 0; + }; + snapshot + .tabs + .iter() + .filter(|tab| { + Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref() + }) + .count() + } + + pub(super) fn resume_mobile_switcher_if_ready(&mut self) -> bool { + if !self.mobile_switcher_suspended || self.overlay.is_some() { + return false; + } + self.mobile_switcher_suspended = false; + if self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.as_ref()) + .is_some() + { + self.mode = self.copy_or_terminal_mode(); + self.navigate_workspace_id = None; + } else { + self.mode = ClientShellMode::Navigate; + } + true + } + + pub(super) fn mobile_layout_active(&self) -> bool { + self.last_composed_size + .is_some_and(|(cols, rows)| !self.layout(cols, rows).mobile_header.is_empty()) + } + + pub(super) fn navigation_workspace_entries( + &self, + snapshot: &ClientShellSnapshot, + ) -> Vec<WorkspaceEntry> { + if self.mobile_layout_active() { + render::workspace_entries(snapshot, &HashSet::new()) + } else { + render::workspace_entries(snapshot, &self.collapsed_groups) + } + } + + pub(super) fn reveal_workspace(&mut self, workspace_id: &str) { + if self + .hits + .workspaces + .iter() + .any(|hit| hit.workspace_id == workspace_id) + { + return; + } + let target = self.snapshot.as_deref().and_then(|snapshot| { + self.navigation_workspace_entries(snapshot) + .iter() + .position(|entry| snapshot.workspaces[entry.index].workspace_id == workspace_id) + }); + if let Some(target) = target { + self.workspace_scroll = target.min(self.hits.workspace_max_scroll); + } + } + + pub(super) fn layout(&self, cols: u16, rows: u16) -> ClientShellLayout { + self.config.layout( + cols, + rows, + self.sidebar_collapsed, + self.focused_tab_count(), + self.sidebar_width, + ) + } + + pub(crate) fn surface_size(&self, cols: u16, rows: u16) -> ClientSurfaceSize { + let surface = self.layout(cols, rows).pane_surface; + ClientSurfaceSize { + cols: surface.width.max(1), + rows: surface.height.max(1), + } + } + + pub(crate) fn set_snapshot(&mut self, snapshot: Box<ClientShellSnapshot>) { + if self.snapshot.as_ref().is_some_and(|current| { + current.boot_id == snapshot.boot_id && snapshot.revision < current.revision + }) { + return; + } + self.graphics.set_scope(&snapshot.boot_id); + let command_bindings_changed = self.snapshot.as_ref().is_none_or(|current| { + current.commands.len() != snapshot.commands.len() + || current + .commands + .iter() + .zip(&snapshot.commands) + .any(|(left, right)| { + left.binding_labels != right.binding_labels || left.action != right.action + }) + }); + let endpoint_profile_changed = self.snapshot.as_ref().is_none_or(|current| { + current.server_keybindings_toml != snapshot.server_keybindings_toml + }); + let snapshot_keybindings_changed = match self.config.keybinding_source { + ClientShellKeybindingSource::Local => self + .snapshot + .as_ref() + .is_none_or(|current| current.commands != snapshot.commands), + ClientShellKeybindingSource::Endpoint => { + endpoint_profile_changed + || self + .snapshot + .as_ref() + .is_none_or(|current| current.commands != snapshot.commands) + } + ClientShellKeybindingSource::RemoteLocal => false, + }; + let active_keymap_changed = match self.config.keybinding_source { + ClientShellKeybindingSource::Local => command_bindings_changed, + ClientShellKeybindingSource::Endpoint => { + endpoint_profile_changed || command_bindings_changed + } + ClientShellKeybindingSource::RemoteLocal => false, + }; + self.config_diagnostic = super::config::merged_config_diagnostic( + self.local_config_diagnostic.as_deref(), + snapshot.config_diagnostic.as_deref(), + ); + let boot_changed = self + .snapshot + .as_ref() + .is_some_and(|current| current.boot_id != snapshot.boot_id); + if boot_changed + || self + .pane_surface + .as_ref() + .is_none_or(|surface| surface.projection_revision != snapshot.revision) + { + self.hits = ShellHitMap::default(); + } + if boot_changed { + self.pane_surface = None; + self.input_leases = ClientInputLeases::default(); + self.popup_terminal_id = None; + self.chrome_drag = None; + self.workspace_press = None; + self.tab_press = None; + self.workspace_scroll = 0; + self.agent_scroll = 0; + self.tab_scroll = 0; + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; + self.mobile_switcher_suspended = false; + self.reveal_focused_tab = true; + self.last_tab_bar_width = None; + self.last_composed_size = None; + self.pending_requests.clear(); + self.pane_scroll_in_flight.clear(); + self.pane_scroll_queued.clear(); + self.pane_scroll_targets.clear(); + self.popup_pending = false; + self.popup_pending_deadline = None; + self.pending_integration_installs = 0; + self.pending_notifications.clear(); + self.visible_notification = None; + self.endpoint_error = None; + self.navigate_workspace_id = None; + self.overlay = self + .config + .startup_onboarding + .then_some(ClientShellOverlay::Onboarding); + self.previous_pane_id = None; + self.pane_mouse_gesture = None; + self.url_click_consumes_until_up = false; + self.replaying_url_click = false; + self.selection = None; + self.last_pane_click = None; + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + self.copy_mode = None; + self.reset_copy_pipeline(); + self.copy_feedback = None; + self.copy_feedback_deadline = None; + self.host_mouse_pixels = None; + self.dismissed_product_announcement = None; + } else if let Some(previous) = self + .snapshot + .as_deref() + .and_then(|current| current.focused_pane_id.as_ref()) + .filter(|previous| Some(previous.as_str()) != snapshot.focused_pane_id.as_deref()) + { + self.previous_pane_id = Some(previous.clone()); + } + if snapshot_keybindings_changed { + if let Err(err) = self.config.apply_snapshot_keybindings( + snapshot.server_keybindings_toml.as_deref(), + &snapshot.commands, + ) { + self.endpoint_error = Some(err); + } else if active_keymap_changed + && matches!( + self.mode, + ClientShellMode::Prefix | ClientShellMode::Navigate | ClientShellMode::Resize + ) + { + self.mode = ClientShellMode::Terminal; + } + } + let tab_layout_changed = self.snapshot.as_deref().is_none_or(|current| { + current.tabs.len() != snapshot.tabs.len() + || current + .tabs + .iter() + .zip(&snapshot.tabs) + .any(|(left, right)| { + left.tab_id != right.tab_id + || left.workspace_id != right.workspace_id + || left.label != right.label + || left.zoomed != right.zoomed + }) + || render::tab_bar_status_width(current) != render::tab_bar_status_width(&snapshot) + }); + if tab_layout_changed + || self + .snapshot + .as_deref() + .and_then(|current| current.focused_tab_id.as_deref()) + != snapshot.focused_tab_id.as_deref() + { + self.reveal_focused_tab = true; + } + if self.selection.as_ref().is_some_and(|selection| { + snapshot.focused_pane_id.as_deref() != Some(selection.pane_id.as_str()) + || !snapshot + .panes + .iter() + .any(|pane| pane.pane_id == selection.pane_id) + }) { + self.selection = None; + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + self.last_pane_click = None; + } + if let Some(copy_pane_id) = self + .copy_mode + .as_ref() + .map(|copy_mode| copy_mode.pane_id.clone()) + { + let pane_exists = snapshot + .panes + .iter() + .any(|pane| pane.pane_id == copy_pane_id); + let pane_focused = snapshot.focused_pane_id.as_deref() == Some(copy_pane_id.as_str()); + if !pane_exists { + self.copy_mode = None; + self.reset_copy_pipeline(); + if self + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == copy_pane_id) + { + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + } + if self.mode == ClientShellMode::Copy { + self.mode = ClientShellMode::Terminal; + } + } else if pane_focused { + if self.mode == ClientShellMode::Terminal { + self.mode = ClientShellMode::Copy; + } + if self.selection.is_none() { + self.sync_copy_selection(); + } + } else { + if self + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == copy_pane_id) + { + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + } + if self.mode == ClientShellMode::Copy { + self.mode = ClientShellMode::Terminal; + } + } + } + if self.mode == ClientShellMode::Navigate + && self.navigate_workspace_id.as_ref().is_none_or(|selected| { + !snapshot + .workspaces + .iter() + .any(|workspace| &workspace.workspace_id == selected) + }) + { + self.navigate_workspace_id = snapshot.focused_workspace_id.clone(); + self.reveal_mobile_workspace = self.mobile_layout_active(); + } + let pane_exists = + |pane_id: &String| snapshot.panes.iter().any(|pane| &pane.pane_id == pane_id); + self.pane_scroll_in_flight + .retain(|pane_id, _| pane_exists(pane_id)); + self.pane_scroll_queued + .retain(|pane_id, _| pane_exists(pane_id)); + self.pane_scroll_targets + .retain(|pane_id, _| pane_exists(pane_id)); + + if !self.config.startup_onboarding { + match snapshot.product_announcement.as_ref() { + Some(announcement) => { + let key = (announcement.version.clone(), announcement.id.clone()); + let already_open = matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::ProductAnnouncement(current)) + if current.version == announcement.version && current.id == announcement.id + ); + let may_open = self.overlay.is_none() + || matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::ProductAnnouncement(_)) + ); + if self.dismissed_product_announcement.as_ref() != Some(&key) + && may_open + && !already_open + { + self.overlay = Some(ClientShellOverlay::ProductAnnouncement( + product_announcement_state(announcement), + )); + } + } + None if matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::ProductAnnouncement(_)) + ) => + { + self.overlay = None; + self.chrome_drag = None; + self.dismissed_product_announcement = None; + } + None => { + self.dismissed_product_announcement = None; + } + } + } + if let Some(ClientShellOverlay::ReleaseNotes(current)) = self.overlay.as_ref() { + match snapshot.release_notes.as_ref() { + Some(notes) + if current.version != notes.version + || current.body != notes.body + || current.preview != notes.preview => + { + self.overlay = + Some(ClientShellOverlay::ReleaseNotes(release_notes_state(notes))); + self.chrome_drag = None; + } + None => { + self.overlay = None; + self.chrome_drag = None; + } + Some(_) => {} + } + } + self.snapshot = Some(snapshot); + self.resume_mobile_switcher_if_ready(); + self.reconcile_input_source(); + } + + pub(crate) fn set_pane_surface(&mut self, mut surface: PaneSurfaceFrame) { + let Some(snapshot) = self.snapshot.as_ref() else { + return; + }; + if surface.boot_id != snapshot.boot_id + || surface.projection_revision < snapshot.revision + || self.pane_surface.as_ref().is_some_and(|current| { + current.boot_id == surface.boot_id + && (surface.projection_revision < current.projection_revision + || (surface.projection_revision == current.projection_revision + && surface.surface_revision < current.surface_revision)) + }) + { + return; + } + if self + .snapshot + .as_ref() + .is_none_or(|snapshot| snapshot.revision != surface.projection_revision) + { + self.hits = ShellHitMap::default(); + } + let previous_popup = self.popup_terminal_id.clone(); + let next_popup = surface + .popup + .as_deref() + .map(|popup| popup.terminal_id.clone()); + if previous_popup != next_popup { + if next_popup.is_some() && matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) + { + self.cancel_settings_overlay(); + } + if let Some(terminal_id) = previous_popup.as_ref() { + self.input_leases + .remove_target(&ClientInputTarget::Popup(terminal_id.clone())); + } + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + if !matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::Onboarding | ClientShellOverlay::ProductAnnouncement(_)) + ) { + self.overlay = self + .config + .startup_onboarding + .then_some(ClientShellOverlay::Onboarding); + } + self.selection = None; + self.last_pane_click = None; + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + self.copy_mode = None; + self.reset_copy_pipeline(); + self.chrome_drag = None; + self.workspace_press = None; + self.tab_press = None; + if self.pane_mouse_gesture.as_ref().is_some_and(|gesture| { + gesture.hit.popup && previous_popup.as_deref() == Some(gesture.hit.pane_id.as_str()) + }) { + self.pane_mouse_gesture = None; + } + self.hits.popup = None; + self.endpoint_error = None; + } + if next_popup.is_some() { + self.popup_pending = false; + self.popup_pending_deadline = None; + } + let selection_content_changed = self.selection.as_ref().is_some_and(|selection| { + let previous_revision = self.pane_surface.as_ref().and_then(|previous| { + previous + .panes + .iter() + .find(|pane| pane.pane_id == selection.pane_id) + .map(|pane| pane.content_revision) + }); + let next_revision = surface + .panes + .iter() + .find(|pane| pane.pane_id == selection.pane_id) + .map(|pane| pane.content_revision); + previous_revision.is_some() + && next_revision.is_some() + && previous_revision != next_revision + }); + if selection_content_changed { + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + } + for pane in &surface.panes { + let Some(target) = self.pane_scroll_targets.get(&pane.pane_id).copied() else { + continue; + }; + let Some(scroll) = pane.scroll else { + continue; + }; + let target = + target.min(usize::try_from(scroll.max_offset_from_bottom).unwrap_or(usize::MAX)); + if usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX) == target { + self.pane_scroll_targets.remove(&pane.pane_id); + } + } + let mut invalidated_copy_pane = None; + if let Some(copy_mode) = self.copy_mode.as_mut() { + if let Some(pane) = surface + .panes + .iter() + .find(|pane| pane.pane_id == copy_mode.pane_id) + { + let geometry = (pane.inner_rect.width, pane.inner_rect.height); + if copy_mode.content_revision != pane.content_revision + || copy_mode.geometry != geometry + { + copy_mode.content_revision = pane.content_revision; + copy_mode.geometry = geometry; + copy_mode.selection = None; + copy_mode.search_matches.clear(); + copy_mode.search_total = 0; + copy_mode.search_current = None; + copy_mode.search_current_global = None; + copy_mode.search_generation = copy_mode.search_generation.saturating_add(1); + copy_mode.copy_after_search = false; + invalidated_copy_pane = Some(copy_mode.pane_id.clone()); + } + if let Some(scroll) = pane.scroll { + let actual_offset = + usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX); + if !self.pane_scroll_targets.contains_key(&pane.pane_id) { + copy_mode.offset_from_bottom = actual_offset; + } + copy_mode.max_offset_from_bottom = + usize::try_from(scroll.max_offset_from_bottom).unwrap_or(usize::MAX); + } + } + } + if invalidated_copy_pane.as_ref().is_some_and(|pane_id| { + self.selection + .as_ref() + .is_some_and(|selection| &selection.pane_id == pane_id) + }) { + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + } + self.popup_terminal_id = next_popup; + self.graphics + .set_scene(std::mem::take(&mut surface.graphics)); + self.pane_surface = Some(surface); + self.resume_mobile_switcher_if_ready(); + self.reconcile_input_source(); + } + + pub(crate) fn tick_popup_pending(&mut self, now: std::time::Instant) { + if self + .popup_pending_deadline + .is_some_and(|deadline| now >= deadline) + { + self.popup_pending = false; + self.popup_pending_deadline = None; + } + } + + pub(crate) fn show_copy_feedback(&mut self, now: std::time::Instant) -> bool { + if !self.config.clipboard_toast_enabled { + return false; + } + self.copy_feedback = Some(crate::app::state::CopyFeedback { + message: "copied to clipboard".to_owned(), + }); + self.copy_feedback_deadline = Some(now + std::time::Duration::from_secs(2)); + true + } + + pub(crate) fn tick_copy_feedback(&mut self, now: std::time::Instant) -> bool { + let mut repaint = false; + if self + .copy_feedback_deadline + .is_some_and(|deadline| now >= deadline) + { + self.copy_feedback = None; + self.copy_feedback_deadline = None; + repaint = true; + } + if self + .selection_highlight_clear_deadline + .is_some_and(|deadline| now >= deadline) + { + self.selection = None; + self.selection_highlight_clear_deadline = None; + repaint = true; + } + repaint + } + + pub(crate) fn timer_delay(&self, now: std::time::Instant) -> std::time::Duration { + let default = std::time::Duration::from_millis(100); + self.selection_autoscroll_deadline + .map(|deadline| deadline.saturating_duration_since(now).min(default)) + .unwrap_or(default) + } + + pub(crate) fn invalidate_pane_surface(&mut self) { + self.pane_surface = None; + self.hits = ShellHitMap::default(); + self.host_mouse_pixels = None; + } + + fn wants_ascii_input(&self) -> bool { + if let Some(overlay) = self.overlay.as_ref() { + return matches!( + overlay, + ClientShellOverlay::ConfirmClose(_) + | ClientShellOverlay::Help(_) + | ClientShellOverlay::Navigator(_) + | ClientShellOverlay::WorktreeRemove(_) + | ClientShellOverlay::ContextMenu(_) + | ClientShellOverlay::GlobalMenu(_) + ); + } + matches!( + self.mode, + ClientShellMode::Prefix + | ClientShellMode::Navigate + | ClientShellMode::Resize + | ClientShellMode::Copy + ) + } + + pub(crate) fn reconcile_input_source(&mut self) { + // Keep the platform restore token while another window has focus. Restoring + // through a global key injection is only safe after this client regains focus. + if self.outer_focused == Some(false) { + return; + } + let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input(); + if desired != self.ascii_input_source_active { + self.ascii_input_source_active = desired; + self.pending_input_source_changes.push(desired); + } + } + + pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> { + std::mem::take(&mut self.pending_input_source_changes) + } +} diff --git a/src/client/shell/surface_patch.rs b/src/client/shell/surface_patch.rs new file mode 100644 index 00000000..10486f50 --- /dev/null +++ b/src/client/shell/surface_patch.rs @@ -0,0 +1,266 @@ +use super::*; + +pub(crate) struct ClientComposedSurfacePatch { + pub(crate) rows: Vec<crate::protocol::PaneSurfacePatchRow>, + pub(crate) cursor: Option<crate::protocol::CursorState>, +} + +pub(crate) enum ClientPaneSurfacePatchOutcome { + Rejected, + Applied(Option<ClientComposedSurfacePatch>), +} + +fn row_fits_frame(row: &crate::protocol::PaneSurfacePatchRow, frame: &FrameData) -> bool { + row.x + .saturating_add(row.cells.len().min(u16::MAX as usize) as u16) + <= frame.width + && row.y < frame.height +} + +fn apply_row(row: &crate::protocol::PaneSurfacePatchRow, frame: &mut FrameData) -> bool { + if !row_fits_frame(row, frame) { + return false; + } + let start = usize::from(row.y) * usize::from(frame.width) + usize::from(row.x); + let end = start + row.cells.len(); + if end > frame.cells.len() { + return false; + } + frame.cells[start..end].clone_from_slice(&row.cells); + true +} + +fn apply_patch_to_surface( + surface: &mut crate::protocol::PaneSurfaceFrame, + patch: &crate::protocol::PaneSurfacePatch, +) -> bool { + for row in &patch.rows { + if !apply_row(row, &mut surface.frame) { + return false; + } + } + for updated in &patch.panes { + let Some(existing) = surface + .panes + .iter_mut() + .find(|pane| pane.pane_id == updated.pane_id) + else { + return false; + }; + *existing = updated.clone(); + } + surface.frame.cursor = patch.cursor.clone(); + surface.surface_revision = patch.surface_revision; + true +} + +fn fast_path_blocker( + state: &ClientShellState, + patch: &crate::protocol::PaneSurfacePatch, +) -> Option<&'static str> { + if state.mode != ClientShellMode::Terminal { + Some("client_surface_patch.fallback.mode") + } else if state.overlay.is_some() { + Some("client_surface_patch.fallback.overlay") + } else if state.endpoint_error.is_some() { + Some("client_surface_patch.fallback.endpoint_error") + } else if state.config_diagnostic.is_some() { + Some("client_surface_patch.fallback.config_diagnostic") + } else if state.visible_notification.is_some() { + Some("client_surface_patch.fallback.notification") + } else if state.copy_feedback.is_some() { + Some("client_surface_patch.fallback.copy_feedback") + } else if state.selection.is_some() { + Some("client_surface_patch.fallback.selection") + } else if state.copy_mode.is_some() { + Some("client_surface_patch.fallback.copy_mode") + } else if state.selection_highlight_clear_deadline.is_some() { + Some("client_surface_patch.fallback.selection_deadline") + } else if patch.panes.iter().any(|pane| { + !state + .hits + .panes + .iter() + .any(|hit| hit.pane_id == pane.pane_id) + }) { + Some("client_surface_patch.fallback.pane_hits") + } else { + None + } +} + +fn pane_geometry_matches( + left: &crate::protocol::PaneSurfacePane, + right: &crate::protocol::PaneSurfacePane, +) -> bool { + left.pane_id == right.pane_id + && left.rect == right.rect + && left.inner_rect == right.inner_rect + && left.focused == right.focused + && left.pixel_width == right.pixel_width + && left.pixel_height == right.pixel_height +} + +impl ClientShellState { + pub(crate) fn apply_pane_surface_patch( + &mut self, + patch: crate::protocol::PaneSurfacePatch, + ) -> ClientPaneSurfacePatchOutcome { + let Some(current) = self.pane_surface.as_ref() else { + return ClientPaneSurfacePatchOutcome::Rejected; + }; + if patch.boot_id != current.boot_id + || patch.projection_revision != current.projection_revision + || patch.base_surface_revision != current.surface_revision + || patch.surface_revision != current.surface_revision.saturating_add(1) + || current.popup.is_some() + || !current.graphics.placements.is_empty() + || !current.graphics.retained_assets.is_empty() + { + return ClientPaneSurfacePatchOutcome::Rejected; + } + + for updated in &patch.panes { + let Some(existing) = current + .panes + .iter() + .find(|pane| pane.pane_id == updated.pane_id) + else { + return ClientPaneSurfacePatchOutcome::Rejected; + }; + if !pane_geometry_matches(existing, updated) { + return ClientPaneSurfacePatchOutcome::Rejected; + } + } + for row in &patch.rows { + if !row_fits_frame(row, ¤t.frame) + || row.cells.is_empty() + || !patch.panes.iter().any(|pane| { + let terminal_row = row.x >= pane.inner_rect.x + && row.y >= pane.inner_rect.y + && row.y < pane.inner_rect.y.saturating_add(pane.inner_rect.height) + && row + .x + .saturating_add(row.cells.len().min(u16::MAX as usize) as u16) + <= pane.inner_rect.x.saturating_add(pane.inner_rect.width); + let scrollbar_rect = pane.scrollbar_rect.or_else(|| { + current + .panes + .iter() + .find(|existing| existing.pane_id == pane.pane_id) + .and_then(|existing| existing.scrollbar_rect) + }); + let scrollbar_row = scrollbar_rect.is_some_and(|rect| { + row.x == rect.x + && row.y >= rect.y + && row.y < rect.y.saturating_add(rect.height) + && row.cells.len() == usize::from(rect.width) + }); + terminal_row || scrollbar_row + }) + { + return ClientPaneSurfacePatchOutcome::Rejected; + } + } + + let fast_path_blocker = fast_path_blocker(self, &patch); + if let Some(reason) = fast_path_blocker { + crate::render_prof::event(reason); + } + let fast_path_area = fast_path_blocker.is_none().then(|| { + let (cols, rows) = self.last_composed_size.unwrap_or_default(); + self.layout(cols, rows).pane_surface + }); + let composed_patch = fast_path_area.map(|area| ClientComposedSurfacePatch { + rows: patch + .rows + .iter() + .map(|row| crate::protocol::PaneSurfacePatchRow { + x: area.x.saturating_add(row.x), + y: area.y.saturating_add(row.y), + cells: row.cells.clone(), + }) + .collect(), + cursor: patch + .cursor + .clone() + .map(|cursor| crate::protocol::CursorState { + x: area.x.saturating_add(cursor.x), + y: area.y.saturating_add(cursor.y), + visible: cursor.visible, + shape: cursor.shape, + }), + }); + if let Some(area) = fast_path_area { + let applied = self + .pane_surface + .as_mut() + .is_some_and(|surface| apply_patch_to_surface(surface, &patch)); + if !applied { + return ClientPaneSurfacePatchOutcome::Rejected; + } + for updated in &patch.panes { + let Some(hit) = self + .hits + .panes + .iter_mut() + .find(|hit| hit.pane_id == updated.pane_id) + else { + continue; + }; + hit.scrollbar_rect = updated.scrollbar_rect.map(|rect| { + Rect::new( + area.x.saturating_add(rect.x), + area.y.saturating_add(rect.y), + rect.width, + rect.height, + ) + }); + hit.scroll = updated.scroll.map(|metrics| crate::pane::ScrollMetrics { + offset_from_bottom: usize::try_from(metrics.offset_from_bottom) + .unwrap_or(usize::MAX), + max_offset_from_bottom: usize::try_from(metrics.max_offset_from_bottom) + .unwrap_or(usize::MAX), + viewport_rows: usize::try_from(metrics.viewport_rows).unwrap_or(usize::MAX), + }); + hit.mouse_reporting = updated.mouse_reporting; + hit.sgr_pixel_mouse = updated.sgr_pixel_mouse; + hit.pixel_width = updated.pixel_width; + hit.pixel_height = updated.pixel_height; + if let (Some(target), Some(scroll)) = ( + self.pane_scroll_targets.get(&updated.pane_id).copied(), + updated.scroll, + ) { + let target = target + .min(usize::try_from(scroll.max_offset_from_bottom).unwrap_or(usize::MAX)); + if usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX) == target { + self.pane_scroll_targets.remove(&updated.pane_id); + } + } + } + self.reconcile_input_source(); + } else { + let mut next = current.clone(); + if !apply_patch_to_surface(&mut next, &patch) { + return ClientPaneSurfacePatchOutcome::Rejected; + } + self.set_pane_surface(next); + } + ClientPaneSurfacePatchOutcome::Applied(composed_patch) + } +} + +#[cfg(test)] +pub(crate) fn apply_composed_surface_patch( + frame: &FrameData, + patch: ClientComposedSurfacePatch, +) -> Option<FrameData> { + let mut next = frame.clone(); + for row in &patch.rows { + if !apply_row(row, &mut next) { + return None; + } + } + next.cursor = patch.cursor; + Some(next) +} diff --git a/src/client/shell/tabs.rs b/src/client/shell/tabs.rs new file mode 100644 index 00000000..cf179d80 --- /dev/null +++ b/src/client/shell/tabs.rs @@ -0,0 +1,386 @@ +use super::*; + +const TAB_SCROLL_BUTTON_WIDTH: u16 = 3; +const MIN_TAB_STRIP_WIDTH: u16 = + MIN_TAB_WIDTH + NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2); + +pub(crate) fn render_tab_bar( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + tab_scroll: &mut usize, + reveal_focused_tab: &mut bool, + tab_drag_insert_index: Option<usize>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + let tabs = snapshot + .tabs + .iter() + .filter(|tab| Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref()) + .collect::<Vec<_>>(); + let desired_widths = tabs + .iter() + .map(|tab| { + let label = tab_label(tab); + display_width(&label).saturating_add(4).max(MIN_TAB_WIDTH) + }) + .collect::<Vec<_>>(); + let content = tab_bar_content_area(snapshot, area); + let mouse_chrome = config.mouse_capture; + let new_tab_width = if mouse_chrome { NEW_TAB_WIDTH } else { 0 }; + let desired_total = desired_widths + .iter() + .copied() + .fold(0_u16, u16::saturating_add) + .saturating_add(tabs.len().saturating_sub(1).min(u16::MAX as usize) as u16) + .saturating_add(new_tab_width); + let overflow = + desired_total > content.width && (!mouse_chrome || content.width >= MIN_TAB_STRIP_WIDTH); + let available = if overflow && mouse_chrome { + content + .width + .saturating_sub(NEW_TAB_WIDTH) + .saturating_sub(TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2)) + } else { + content.width.saturating_sub(new_tab_width) + }; + let max_scroll = max_tab_scroll(&desired_widths, available); + if !overflow { + *tab_scroll = 0; + } else if *reveal_focused_tab { + if let Some(focused) = tabs.iter().position(|tab| tab.focused) { + *tab_scroll = centered_tab_scroll(focused, &desired_widths, available).min(max_scroll); + } + } else { + *tab_scroll = (*tab_scroll).min(max_scroll); + } + *reveal_focused_tab = false; + + let mut x = content.x; + let tab_right = if overflow && mouse_chrome { + hits.tab_scroll_left = Rect::new( + content.x, + content.y, + TAB_SCROLL_BUTTON_WIDTH.min(content.width), + 1, + ); + put_text( + buffer, + hits.tab_scroll_left.x, + content.y, + hits.tab_scroll_left.width, + " < ", + Style::default() + .fg(if *tab_scroll > 0 { + palette.overlay1 + } else { + palette.overlay0 + }) + .bg(palette.surface0), + ); + x = hits.tab_scroll_left.right(); + content + .right() + .saturating_sub(NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH) + } else { + content.right().saturating_sub(new_tab_width) + }; + + let mut first_visible = None; + let mut last_visible = None; + for (index, tab) in tabs.iter().enumerate().skip(*tab_scroll) { + let name = tab_label(tab); + let desired = desired_widths[index]; + let remaining = tab_right.saturating_sub(x); + let width = desired.min(remaining); + if width == 0 { + break; + } + let rect = Rect::new(x, area.y, width, 1); + let style = if tab.focused { + let base = Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent); + if tab.custom_label { + base.add_modifier(Modifier::BOLD) + } else { + base + } + } else if tab.custom_label { + Style::default().fg(palette.overlay1).bg(palette.surface0) + } else { + Style::default() + .fg(palette.overlay0) + .bg(palette.surface0) + .add_modifier(Modifier::DIM) + }; + let padding = width.saturating_sub(display_width(&name)); + let left = padding / 2; + let text = format!( + "{empty:left$}{name}{empty:right_padding$}", + empty = "", + left = left as usize, + right_padding = padding.saturating_sub(left) as usize, + ); + put_text(buffer, rect.x, rect.y, rect.width, &text, style); + hits.tabs.push((rect, tab.tab_id.clone())); + first_visible.get_or_insert(index); + last_visible = Some(index); + x = x.saturating_add(width + 1); + if width < desired { + break; + } + } + + if overflow && mouse_chrome { + hits.tab_scroll_right = Rect::new(tab_right, area.y, TAB_SCROLL_BUTTON_WIDTH, 1); + let can_scroll_right = last_visible.is_some_and(|index| index + 1 < tabs.len()); + put_text( + buffer, + hits.tab_scroll_right.x, + area.y, + hits.tab_scroll_right.width, + " > ", + Style::default() + .fg(if can_scroll_right { + palette.overlay1 + } else { + palette.overlay0 + }) + .bg(palette.surface0), + ); + hits.new_tab = Rect::new( + hits.tab_scroll_right.right(), + area.y, + content + .right() + .saturating_sub(hits.tab_scroll_right.right()) + .min(NEW_TAB_WIDTH), + 1, + ); + } else if mouse_chrome { + hits.new_tab = Rect::new( + x.min(content.right()), + area.y, + content.right().saturating_sub(x).min(NEW_TAB_WIDTH), + 1, + ); + } + if mouse_chrome { + put_text( + buffer, + hits.new_tab.x, + area.y, + hits.new_tab.width, + " + ", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } + + if first_visible.is_some_and(|index| index > 0) { + let ellipsis_x = if hits.tab_scroll_left.width > 0 { + hits.tab_scroll_left.right() + } else { + content.x + }; + put_text( + buffer, + ellipsis_x, + area.y, + u16::from(ellipsis_x < content.right()), + "…", + Style::default().fg(palette.overlay0), + ); + } + if last_visible.is_some_and(|index| index + 1 < tabs.len()) { + let ellipsis_x = if hits.tab_scroll_right.width > 0 { + hits.tab_scroll_right.x.saturating_sub(1) + } else { + content.right().saturating_sub(1) + }; + put_text( + buffer, + ellipsis_x, + area.y, + u16::from(ellipsis_x >= content.x && ellipsis_x < content.right()), + "…", + Style::default().fg(palette.overlay0), + ); + } + + if let Some(insert_index) = tab_drag_insert_index { + if let Some(indicator_x) = tab_drop_indicator_x(hits, &tabs, insert_index) { + put_text( + buffer, + indicator_x.min(content.right().saturating_sub(1)), + area.y, + 1, + "│", + Style::default().fg(palette.accent), + ); + } + } + render_tab_bar_status(buffer, area, snapshot, palette); +} + +pub(crate) fn tab_bar_status_width(snapshot: &ClientShellSnapshot) -> u16 { + let content = snapshot.tab_bar_right.iter().fold(0u16, |width, segment| { + width.saturating_add(display_width(&segment.text)) + }); + let separators = snapshot.tab_bar_right.len().saturating_sub(1); + content.saturating_add( + display_width(&snapshot.tab_bar_right_separator) + .saturating_mul(separators.min(u16::MAX as usize) as u16), + ) +} + +fn tab_bar_status_area(snapshot: &ClientShellSnapshot, area: Rect) -> Option<Rect> { + let width = tab_bar_status_width(snapshot); + if width == 0 { + return None; + } + let reserved = width.saturating_add(1); + (area.width.saturating_sub(reserved) >= MIN_TAB_STRIP_WIDTH) + .then(|| Rect::new(area.right().saturating_sub(width), area.y, width, 1)) +} + +fn tab_bar_content_area(snapshot: &ClientShellSnapshot, area: Rect) -> Rect { + let reserved = tab_bar_status_area(snapshot, area) + .map(|status| status.width.saturating_add(1)) + .unwrap_or(0); + Rect { + width: area.width.saturating_sub(reserved), + ..area + } +} + +fn render_tab_bar_status( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + palette: &Palette, +) { + let Some(status) = tab_bar_status_area(snapshot, area) else { + return; + }; + let separator_width = display_width(&snapshot.tab_bar_right_separator); + let mut x = status.x; + for (index, segment) in snapshot.tab_bar_right.iter().enumerate() { + if index > 0 && separator_width > 0 { + put_text( + buffer, + x, + area.y, + separator_width, + &snapshot.tab_bar_right_separator, + Style::default().fg(palette.overlay0).bg(palette.panel_bg), + ); + x = x.saturating_add(separator_width); + } + let width = display_width(&segment.text); + let style = if segment.accent { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.overlay1).bg(palette.panel_bg) + }; + put_text(buffer, x, area.y, width, &segment.text, style); + x = x.saturating_add(width); + } +} + +fn tab_drop_indicator_x( + hits: &ShellHitMap, + tabs: &[&ClientShellTab], + insert_index: usize, +) -> Option<u16> { + let visible = hits + .tabs + .iter() + .filter_map(|(rect, tab_id)| { + tabs.iter() + .position(|tab| tab.tab_id == *tab_id) + .map(|index| (index, *rect)) + }) + .collect::<Vec<_>>(); + let (first_index, first_rect) = *visible.first()?; + let (last_index, last_rect) = *visible.last()?; + if insert_index == 0 { + return Some(if first_index == 0 { + first_rect.x + } else { + hits.tab_scroll_left.right() + }); + } + if let Some((_, rect)) = visible.iter().find(|(index, _)| *index == insert_index) { + return Some(rect.x.saturating_sub(1)); + } + if insert_index >= tabs.len() { + return Some(if last_index + 1 >= tabs.len() { + last_rect.right() + } else { + hits.tab_scroll_right.x.saturating_sub(1) + }); + } + None +} + +fn centered_tab_scroll(focused: usize, widths: &[u16], available: u16) -> usize { + let mut best = focused; + let mut best_distance = u16::MAX; + for start in 0..=focused { + let before = widths + .iter() + .copied() + .enumerate() + .skip(start) + .take(focused.saturating_sub(start)) + .fold(0u16, |width, (_, tab)| width.saturating_add(tab + 1)); + if before >= available { + continue; + } + let focused_width = widths[focused].min(available.saturating_sub(before)); + let center = before.saturating_mul(2).saturating_add(focused_width); + let distance = center.abs_diff(available); + if distance <= best_distance { + best_distance = distance; + best = start; + } + } + best +} + +fn max_tab_scroll(widths: &[u16], available: u16) -> usize { + (0..widths.len()) + .find(|start| last_visible_tab(*start, widths, available) == widths.len().checked_sub(1)) + .unwrap_or(0) +} + +fn last_visible_tab(start: usize, widths: &[u16], available: u16) -> Option<usize> { + let mut remaining = available; + let mut last = None; + for (index, width) in widths.iter().copied().enumerate().skip(start) { + if remaining == 0 { + break; + } + last = Some(index); + if width >= remaining { + break; + } + remaining = remaining.saturating_sub(width.saturating_add(1)); + } + last +} + +fn tab_label(tab: &ClientShellTab) -> String { + if tab.zoomed { + format!("{} Z", tab.label) + } else { + tab.label.clone() + } +} diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs new file mode 100644 index 00000000..0ee5aa3f --- /dev/null +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -0,0 +1,1248 @@ +use super::*; + +#[test] +fn mouse_hits_use_stable_workspace_tab_and_pane_ids() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + + let workspace_down = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 2, + row: 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(workspace_down.actions.is_empty()); + let workspace = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 2, + row: 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(workspace.requests.is_empty()); + let [ClientShellAction::Endpoint { request, .. }] = &workspace.actions[..] else { + panic!("workspace click should use the endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_1" + )); + + let pane = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 27, + row: 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(pane.requests.is_empty()); + let [ClientShellAction::Endpoint { request, .. }] = &pane.actions[..] else { + panic!("pane click should use the endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + )); +} + +#[test] +fn collapsed_workspace_jitter_remains_a_click() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.sidebar_collapsed = true; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("collapsed sidebar"); + let workspace = state.hits.workspaces[0].rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: workspace.x, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: workspace.x + 1, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.chrome_drag.is_none()); + assert!(state.workspace_press.is_some()); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: workspace.x + 1, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &release.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_1" + ) + )); +} + +#[test] +fn grouped_worktrees_render_parent_branch_and_indented_child() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + let mut snapshot = snapshot(); + snapshot.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: false, + }); + snapshot.workspaces.push(ClientShellWorkspace { + workspace_id: "ws_2".into(), + active_tab_id: "tab_ws2".into(), + new_workspace_cwd: "/repo/feature".into(), + number: 2, + label: "repo-feature".into(), + custom_label: false, + branch: Some("worktree/feature".into()), + git_ahead_behind: None, + tokens: Vec::new(), + worktree: Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: true, + }), + focused: false, + agent_status: AgentStatus::Idle, + }); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + let frame = state.compose(106, 20).expect("composed frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("main")); + assert!(text.contains("└─")); + assert!(text.contains("feature")); + + let mut replacement = (**state.snapshot.as_ref().expect("snapshot")).clone(); + replacement.revision = 2; + replacement.focused_workspace_id = Some("ws_2".into()); + replacement.workspaces[0].focused = false; + replacement.workspaces[1].focused = true; + replacement.workspaces[1].agent_status = AgentStatus::Blocked; + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + state.collapsed_groups.insert("repo".into()); + state.set_snapshot(Box::new(replacement)); + state.set_pane_surface(replacement_surface); + let collapsed = state.compose(106, 20).expect("collapsed worktree group"); + let parent = state.hits.workspaces[0].rect; + let status_cell = usize::from(parent.y) * usize::from(collapsed.width) + + usize::from(parent.x.saturating_add(1)); + assert_eq!( + collapsed.cells[status_cell].fg, + crate::protocol::color_to_u32(state.config.palette.red) + ); + + let mut next = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NextWorkspace), + &mut next, + ); + assert!(matches!( + &next.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_1" + ) + )); +} + +#[test] +fn workspace_click_waits_for_release_and_drag_reorders_by_stable_id() { + let mut projected = snapshot(); + for index in 2..=3 { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{index}"); + workspace.number = index; + workspace.label = format!("workspace-{index}"); + workspace.focused = false; + projected.workspaces.push(workspace); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 24).expect("three workspaces"); + let first = state.hits.workspaces[0].rect; + let third = state.hits.workspaces[2].rect; + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x + 2, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(down.actions.is_empty()); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.x + 2, + row: third.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(drag.repaint); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::Workspace { + ref source_workspace_id, + target: Some((None, _)), + }) if source_workspace_id == "ws_1" + )); + let frame = state.compose(106, 24).expect("workspace drop indicator"); + assert!(frame + .cells + .chunks(frame.width as usize) + .any(|row| row.iter().take(20).any(|cell| cell.symbol == "─"))); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: third.x + 2, + row: third.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &release.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceMove(params) + if params.workspace_id == "ws_1" && params.insert_index == 3 + ) + )); + + state.compose(106, 24).expect("workspaces after drag"); + let second = state.hits.workspaces[1].rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: second.x + 2, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: second.x + 2, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &click.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_2" + ) + )); +} + +#[test] +fn workspace_drag_moves_parent_worktree_as_one_block_and_rejects_child() { + let mut projected = snapshot(); + projected.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: false, + }); + let mut child = projected.workspaces[0].clone(); + child.workspace_id = "ws_child".into(); + child.number = 2; + child.label = "feature".into(); + child.focused = false; + child.worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: true, + }); + let mut other = projected.workspaces[0].clone(); + other.workspace_id = "ws_other".into(); + other.number = 3; + other.label = "other".into(); + other.focused = false; + other.worktree = None; + projected.workspaces.extend([child, other]); + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 24).expect("worktree workspaces"); + assert!(state.hits.workspaces[1].indented); + let parent = state.hits.workspaces[0].rect; + let child = state.hits.workspaces[1].rect; + let other = state.hits.workspaces[2].rect; + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: parent.x + 2, + row: parent.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: other.x + 2, + row: other.bottom(), + modifiers: KeyModifiers::empty(), + })]); + let moved = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: other.x + 2, + row: other.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &moved.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceMoveBlock(params) + if params.workspace_ids == ["ws_1", "ws_child"] + && params.before_workspace_id.is_none() + ) + )); + + state.compose(106, 24).expect("worktree child"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: child.x + 2, + row: child.y, + modifiers: KeyModifiers::empty(), + })]); + let dragging_child = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: other.x + 2, + row: other.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(dragging_child.actions.is_empty()); + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn pane_cycle_last_and_agent_actions_resolve_to_stable_pane_ids() { + let mut initial = snapshot(); + let mut second = initial.panes[0].clone(); + second.pane_id = "pane_2".into(); + second.focused = false; + initial.panes.push(second); + initial.agents = vec![ + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("first".into()), + display_agent: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }, + ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("second".into()), + display_agent: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 2, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }, + ]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(initial.clone())); + + let mut cycle = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CyclePaneNext), + &mut cycle, + ); + let [ClientShellAction::Endpoint { request, .. }] = &cycle.actions[..] else { + panic!("pane cycle should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + )); + + let mut replacement = initial; + replacement.revision = 2; + replacement.focused_pane_id = Some("pane_2".into()); + replacement.panes[0].focused = false; + replacement.panes[1].focused = true; + state.set_snapshot(Box::new(replacement)); + let mut last = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::LastPane), + &mut last, + ); + let [ClientShellAction::Endpoint { request, .. }] = &last.actions[..] else { + panic!("last pane should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + )); + + let mut agent = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(1)), + &mut agent, + ); + let [ClientShellAction::Endpoint { request, .. }] = &agent.actions[..] else { + panic!("agent focus should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + )); +} + +#[test] +fn agent_sidebar_honors_priority_symbols_tokens_and_stable_hits() { + let mut projected = snapshot(); + let mut second_pane = projected.panes[0].clone(); + second_pane.pane_id = "pane_2".into(); + second_pane.focused = false; + projected.panes.push(second_pane); + projected.agents = vec![ + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi one".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: Some("first title".into()), + terminal_title_stripped: Some("first".into()), + agent_status: AgentStatus::Done, + state_change_seq: 10, + state_labels: Vec::new(), + tokens: vec![("summary".into(), "review complete".into())], + focused: true, + }, + ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi two".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: Some("second title".into()), + terminal_title_stripped: Some("second".into()), + agent_status: AgentStatus::Blocked, + state_change_seq: 20, + state_labels: vec![("blocked".into(), "needs input".into())], + tokens: vec![("summary".into(), "waiting for Can".into())], + focused: false, + }, + ]; + let mut config = Config::default(); + config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; + config.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; + config.ui.sidebar.agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + config.ui.sidebar.agents.rows_by_agent.insert( + "pi".into(), + vec![ + vec![ + crate::config::AgentSidebarToken::StateIcon, + crate::config::AgentSidebarToken::StateText, + ], + vec![ + crate::config::AgentSidebarToken::Agent, + crate::config::AgentSidebarToken::Custom("summary".into()), + ], + ], + ); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 30).expect("agent sidebar frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("× needs input"), "frame: {text}"); + assert!(text.contains("pi two"), "frame: {text}"); + assert!(text.contains("waiting for"), "frame: {text}"); + assert_eq!( + state + .hits + .agents + .first() + .map(|(_, pane_id)| pane_id.as_str()), + Some("pane_2") + ); + + let first = state.hits.agents[0].0; + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &click.actions[..] else { + panic!("agent row should focus through endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + )); + + state.compose(106, 10).expect("short agent sidebar frame"); + assert_eq!( + state + .hits + .agents + .first() + .map(|(_, pane_id)| pane_id.as_str()), + Some("pane_2") + ); + let body = state.hits.agent_body; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: body.x, + row: body.y, + modifiers: KeyModifiers::empty(), + })]); + state + .compose(106, 10) + .expect("scrolled agent sidebar frame"); + assert_eq!( + state + .hits + .agents + .first() + .map(|(_, pane_id)| pane_id.as_str()), + Some("pane_1") + ); + + state.sidebar_collapsed = true; + let compact = state.compose(106, 30).expect("compact agent sidebar frame"); + let blocked = state + .hits + .agents + .iter() + .find(|(_, pane_id)| pane_id == "pane_2") + .expect("blocked compact agent") + .0; + let row_start = blocked.y as usize * compact.width as usize + blocked.x as usize; + assert_ne!(compact.cells[row_start].fg, compact.cells[row_start + 2].fg); + assert_eq!(compact.cells[row_start].bg, compact.cells[row_start + 2].bg); +} + +#[test] +fn active_agent_view_controls_sidebar_order_and_focus_indices() { + let mut projected = snapshot(); + let mut second_pane = projected.panes[0].clone(); + second_pane.pane_id = "pane_2".into(); + second_pane.focused = false; + projected.panes.push(second_pane.clone()); + let mut third_pane = second_pane; + third_pane.pane_id = "pane_3".into(); + projected.panes.push(third_pane); + projected.agents = vec![ + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("first".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }, + ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("second".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Blocked, + state_change_seq: 2, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }, + ClientShellAgent { + pane_id: "pane_3".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("third".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 3, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }, + ]; + projected.agent_view_label = Some("review".into()); + projected.agent_order = vec!["pane_2".into(), "pane_3".into()]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("filtered agent sidebar"); + assert_eq!( + state + .hits + .agents + .iter() + .map(|(_, pane_id)| pane_id.as_str()) + .collect::<Vec<_>>(), + vec!["pane_2", "pane_3"] + ); + assert_eq!(state.hits.agent_sort_toggle, Rect::default()); + + let mut focus = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(0)), + &mut focus, + ); + assert!(matches!( + &focus.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + ) + )); + + let mut next = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NextAgent), + &mut next, + ); + assert!(matches!( + &next.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + ) + )); +} + +#[test] +fn agent_sort_toggle_is_client_local_and_persists_per_endpoint() { + let path = std::env::temp_dir().join(format!( + "herdr-shell-agent-sort-{}-{}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let mut projected = snapshot(); + projected.agents.push(ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Working, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }); + let config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("agent sidebar frame"); + let toggle = state.hits.agent_sort_toggle; + + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: toggle.x, + row: toggle.y, + modifiers: KeyModifiers::empty(), + })]); + + assert_eq!( + state.config.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert!(click.actions.is_empty()); + let reloaded_config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let reloaded = ClientShellState::new(reloaded_config); + assert_eq!( + reloaded.config.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert!(reloaded.agent_panel_sort_manual); + std::fs::remove_file(path).expect("remove agent sort preferences"); +} + +#[test] +fn workspace_actions_preserve_selected_target_and_client_confirmation() { + let mut snapshot = snapshot(); + let mut second = snapshot.workspaces[0].clone(); + second.workspace_id = "ws_2".into(); + second.number = 2; + second.label = "second".into(); + second.focused = false; + snapshot.workspaces.push(second); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.mode = ClientShellMode::Navigate; + state.navigate_workspace_id = Some("ws_2".into()); + + let rename = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('w'), + KeyModifiers::SHIFT, + ))]); + assert!(rename.actions.is_empty()); + assert!(matches!( + state.overlay.as_ref(), + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::Workspace { workspace_id }, + .. + })) if workspace_id == "ws_2" + )); + assert!(state.handle_input_bytes(&[0x15]).actions.is_empty()); + assert!(state.handle_input_bytes(b"renamed").actions.is_empty()); + let save = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &save.actions[..] else { + panic!("workspace rename should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceRename(params) + if params.workspace_id == "ws_2" && params.label == "renamed" + )); + + state.navigate_workspace_id = Some("ws_2".into()); + let mut close = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CloseWorkspace), + &mut close, + ); + assert!(close.actions.is_empty()); + assert!(matches!( + state.overlay.as_ref(), + Some(ClientShellOverlay::ConfirmClose(ClientConfirmCloseOverlay { + workspace_id, + .. + })) if workspace_id == "ws_2" + )); + let confirm = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &confirm.actions[..] else { + panic!("workspace confirmation should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceClose(params) + if params.workspace_id == "ws_2" && params.close_group + )); +} + +#[test] +fn desktop_workspace_navigation_reveals_overflowing_selection() { + let mut projected = snapshot(); + for index in 2..=8 { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{index}"); + workspace.number = index; + workspace.label = format!("workspace-{index}"); + workspace.focused = false; + projected.workspaces.push(workspace); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.navigate_workspace_id = Some("ws_1".into()); + state.compose(106, 12).expect("overflowing sidebar"); + + for _ in 0..6 { + state.handle_input_bytes(b"\x1b[B"); + state.compose(106, 12).expect("revealed workspace"); + let selected = state.navigate_workspace_id.as_deref().expect("selection"); + assert!( + state + .hits + .workspaces + .iter() + .any(|hit| hit.workspace_id == selected), + "{selected} should remain visible" + ); + } +} + +#[test] +fn named_workspace_overlay_targets_projected_source_workspace() { + let mut config = Config::default(); + config.ui.prompt_new_workspace_name = true; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace), + &mut open, + ); + assert!(matches!( + state.overlay.as_ref(), + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + input: value, + target: ClientRenameTarget::NewWorkspace { + source_workspace_id, + .. + }, + .. + })) if value == "repo" && source_workspace_id.as_deref() == Some("ws_1") + )); + let create = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else { + panic!("named workspace should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceCreate(params) + if params.source_workspace_id.as_deref() == Some("ws_1") + && params.cwd.as_deref() == Some("/repo") + && params.label.is_none() + )); +} + +#[test] +fn navigate_mode_selects_workspace_locally_then_focuses_by_stable_id() { + let mut snapshot = snapshot(); + let mut second = snapshot.workspaces[0].clone(); + second.workspace_id = "ws_2".into(); + second.number = 2; + second.label = "second".into(); + second.focused = false; + snapshot.workspaces.push(second); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + + assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); + let enter_navigate = state.handle_input_bytes(b"w"); + assert!(enter_navigate.repaint); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1")); + + let invalid = state.handle_input_bytes(b"9"); + assert!(invalid.actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1")); + + let move_selection = state.handle_input_bytes(b"\x1b[B"); + assert!(move_selection.actions.is_empty()); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_2")); + let frame = state.compose(106, 20).expect("navigate frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("second")); + assert!(text.contains("NAVIGATE")); + + let focus = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &focus.actions[..] else { + panic!("selected workspace should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_2" + )); + assert_eq!(state.mode, ClientShellMode::Terminal); +} + +#[test] +fn worktree_create_previews_the_endpoint_owned_checkout_path() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut prepare = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("new worktree should prepare through worktree.list"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeList(params) + if params.workspace_id.as_deref() == Some("ws_1") + )); + let request_id = request.id.clone(); + assert!( + state + .handle_endpoint_result("boot-1", &request_id, Ok(worktree_list_result(None))) + .0 + ); + let frame = state.compose(106, 30).expect("new worktree modal"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("new worktree")); + assert!(text.contains("create and open")); + assert!(frame.cursor.as_ref().is_some_and(|cursor| cursor.visible)); + + assert!(state + .handle_input_bytes(b"feature/client-shell") + .actions + .is_empty()); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) + if create.checkout_path + == "/tmp/herdr-worktrees/repo/feature-client-shell" + )); + let submit = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &submit.actions[..] else { + panic!("worktree create should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeCreate(params) + if params.workspace_id.as_deref() == Some("ws_1") + && params.branch.as_deref() == Some("feature/client-shell") + && params.path.is_none() + && params.focus + )); +} + +#[test] +fn worktree_open_filters_and_clicks_a_stable_public_entry() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut prepare = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("open worktree should prepare through worktree.list"); + }; + let request_id = request.id.clone(); + state.handle_endpoint_result("boot-1", &request_id, Ok(worktree_list_result(None))); + let frame = state.compose(106, 30).expect("open worktree modal"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("feature")); + let row = state.hits.worktree_rows[0].0; + let open = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: row.x + 2, + row: row.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &open.actions[..] else { + panic!("worktree row should open through endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeOpen(params) + if params.workspace_id.as_deref() == Some("ws_1") + && params.path.as_deref() == Some("/repo-feature") + && params.focus + )); +} + +#[test] +fn worktree_remove_escalates_dirty_failure_to_force_confirmation() { + let mut snapshot = snapshot(); + snapshot.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo-key".into(), + label: "repo".into(), + is_linked_worktree: true, + }); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + let mut prepare = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::RemoveWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("remove worktree should prepare through worktree.list"); + }; + let request_id = request.id.clone(); + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(worktree_list_result(Some("ws_1"))), + ); + let remove = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &remove.actions[..] else { + panic!("worktree remove should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeRemove(params) + if params.workspace_id == "ws_1" && !params.force + )); + let request_id = request.id.clone(); + state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("dirty_worktree_requires_force".into()), + message: "dirty worktree".into(), + }), + ); + let frame = state.compose(106, 30).expect("force remove modal"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("delete anyway")); + assert!(text.contains("permanently deleted")); + let force = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &force.actions[..] else { + panic!("forced worktree remove should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeRemove(params) + if params.workspace_id == "ws_1" && params.force + )); +} + +#[test] +fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.toast_delivery = crate::config::ToastDelivery::Herdr; + config.toast_delay_seconds = 0; + let mut state = ClientShellState::new(config); + let mut projected = snapshot(); + projected.agents.push(ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_2".into(), + tab_id: "tab_2".into(), + name: None, + display_agent: Some("codex".into()), + agent: Some("codex".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Blocked, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + let now = std::time::Instant::now(); + let (effects, repaint) = state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "codex needs attention".into(), + body: Some("other · 2".into()), + sound: Some(SemanticNotificationSound::Request), + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + assert!(repaint); + assert!(matches!( + effects.as_slice(), + [ClientShellNotificationEffect::Sound { + sound: crate::sound::Sound::Request, + .. + }] + )); + let frame = state.compose(100, 28).expect("notification frame"); + let rendered = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(rendered.contains("codex needs attention")); + let hit = state.hits.notification_toast; + let click = || { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.x, + row: hit.y, + modifiers: KeyModifiers::empty(), + }) + }; + state.mode = ClientShellMode::Navigate; + let ignored = state.handle_raw_events(vec![click()]); + assert!(ignored.actions.is_empty()); + assert!(state.visible_notification.is_some()); + + state.mode = ClientShellMode::Terminal; + let outcome = state.handle_raw_events(vec![click()]); + assert!(outcome.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_2" + ) + ))); + assert!(state.visible_notification.is_none()); + + state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "codex needs attention".into(), + body: None, + sound: None, + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + let mut keybind = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenNotificationTarget), + &mut keybind, + ); + assert!(keybind.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_2" + ) + ))); + assert!(state.visible_notification.is_none()); + + state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "first".into(), + body: None, + sound: None, + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + assert!(state.visible_notification.is_some()); + state.config.toast_delay_seconds = 1; + let (_, repaint) = state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "replacement".into(), + body: None, + sound: None, + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + assert!(repaint); + assert!(state.visible_notification.is_none()); + assert_eq!(state.pending_notifications.len(), 1); +} diff --git a/src/client/shell/tests/chrome_context.rs b/src/client/shell/tests/chrome_context.rs new file mode 100644 index 00000000..eeb272c2 --- /dev/null +++ b/src/client/shell/tests/chrome_context.rs @@ -0,0 +1,339 @@ +use super::*; + +#[test] +fn tab_overflow_controls_scroll_the_client_owned_tab_bar() { + let mut snapshot = snapshot(); + snapshot.tabs.extend((2..=8).map(|number| ClientShellTab { + tab_id: format!("tab_{number}"), + workspace_id: "ws_1".into(), + number, + label: number.to_string(), + custom_label: false, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + })); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + state.compose(80, 20).expect("overflow tab bar"); + + assert!(state.hits.tab_scroll_right.width > 0); + let scroll_right = state.hits.tab_scroll_right; + let outcome = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: scroll_right.x + 1, + row: scroll_right.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(outcome.repaint); + assert_eq!(state.tab_scroll, 1); + + let mut update = state.snapshot.as_deref().expect("snapshot").clone(); + update.focused_tab_id = Some("tab_8".into()); + for tab in &mut update.tabs { + tab.focused = tab.tab_id == "tab_8"; + } + state.set_snapshot(Box::new(update)); + state.compose(80, 20).expect("focused overflow tab"); + assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_8")); + + state.compose(300, 20).expect("tabs without overflow"); + assert_eq!(state.tab_scroll, 0); + assert_eq!(state.hits.tabs.len(), 8); + state.compose(80, 20).expect("focused tab after narrowing"); + assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_8")); +} + +#[test] +fn client_owned_sidebar_dividers_resize_live() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("expanded sidebar"); + let workspace_body = state.hits.workspace_body; + let needless_scroll = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: workspace_body.x, + row: workspace_body.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.hits.workspace_max_scroll, 0); + assert_eq!(state.workspace_scroll, 0); + assert!(!needless_scroll.repaint); + let width_divider = state.hits.sidebar_divider; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: width_divider.x, + row: width_divider.y + 2, + modifiers: KeyModifiers::empty(), + })]); + let resize = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 31, + row: width_divider.y + 2, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.sidebar_width, 32); + assert!(state.sidebar_width_manual); + assert!(resize.repaint); + assert!(resize.resize); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 31, + row: width_divider.y + 2, + modifiers: KeyModifiers::empty(), + })]); + + state.set_pane_surface(surface()); + state.compose(106, 30).expect("resized sidebar"); + let section_divider = state.hits.sidebar_section_divider; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: section_divider.x + 2, + row: section_divider.y, + modifiers: KeyModifiers::empty(), + })]); + let split = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: section_divider.x + 2, + row: 20, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.sidebar_section_split > 0.6); + assert!(split.repaint); + assert!(!split.resize); +} + +#[test] +fn context_menus_capture_stable_targets_and_route_actions() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + + let workspace = state.hits.workspaces[0].rect; + let open_workspace_menu = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: workspace.x + 2, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(open_workspace_menu.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Workspace { ref workspace_id, .. }, + .. + })) if workspace_id == "ws_1" + )); + let workspace_items = match state.overlay.as_ref() { + Some(ClientShellOverlay::ContextMenu(menu)) => menu.items(), + _ => panic!("workspace context menu"), + }; + assert!(workspace_items + .iter() + .any(|item| item.action == ClientContextMenuAction::NewWorktree)); + state.compose(106, 20).expect("workspace context menu"); + let rename = state.hits.context_menu_rows[0].0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rename.x + 1, + row: rename.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::Workspace { ref workspace_id }, + .. + })) if workspace_id == "ws_1" + )); + + state.overlay = None; + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: pane.x + 1, + row: pane.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(106, 20).expect("pane context menu"); + let split_index = match state.overlay.as_ref() { + Some(ClientShellOverlay::ContextMenu(menu)) => menu + .items() + .iter() + .position(|item| item.action == ClientContextMenuAction::SplitRight) + .expect("split right item"), + _ => panic!("pane context menu"), + }; + let split = state.hits.context_menu_rows[split_index].0; + let outcome = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: split.x + 1, + row: split.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("pane split context action should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneSplit(params) + if params.target_pane_id.as_deref() == Some("pane_1") + && params.direction == crate::api::schema::SplitDirection::Right + )); +} + +#[test] +fn global_menu_opens_from_sidebar_and_routes_client_actions() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("shell frame"); + let launcher = state.hits.global_launcher; + assert_ne!(launcher, Rect::default()); + + let open = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: launcher.x, + row: launcher.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(open.repaint); + let menu = state.compose(106, 30).expect("global menu"); + let text = menu + .cells + .chunks(menu.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("settings")); + assert!(text.contains("keybinds")); + assert!(text.contains("reload config")); + assert!(text.contains("detach")); + + let keybinds = state.hits.global_menu_rows[1].0; + let help = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: keybinds.x, + row: keybinds.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(help.actions.is_empty()); + assert!(matches!(state.overlay, Some(ClientShellOverlay::Help(_)))); + + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 3, + })); + let detach = state.handle_input_bytes(b"\r"); + assert!(detach.detach); + assert!(state.overlay.is_none()); +} + +#[test] +fn new_tab_overlay_owns_text_cursor_and_submits_public_api_request() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab), + &mut open, + ); + assert!(open.actions.is_empty()); + let frame = state.compose(106, 20).expect("new tab overlay"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("new tab")); + assert!(text.contains("save")); + let restored = frame.to_ratatui_buffer().expect("overlay frame"); + assert!(!restored + .cell((26, 7)) + .expect("overlay title cell") + .modifier + .contains(Modifier::DIM)); + assert!(frame.cursor.as_ref().is_some_and(|cursor| cursor.visible)); + + assert!(state.handle_input_bytes(b"logs").actions.is_empty()); + let create = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else { + panic!("new tab save should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::TabCreate(params) + if params.workspace_id.as_deref() == Some("ws_1") + && params.label.as_deref() == Some("logs") + )); + assert!(state.overlay.is_none()); +} + +#[test] +fn close_confirmation_error_becomes_client_owned_overlay_and_stable_group_close() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut close = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::ClosePane), + &mut close, + ); + let [ClientShellAction::Endpoint { request, .. }] = &close.actions[..] else { + panic!("pane close should use endpoint API"); + }; + let request_id = request.id.clone(); + assert!( + state + .handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("confirmation_required".into()), + message: "confirmation required".into(), + }), + ) + .0 + ); + let frame = state.compose(106, 20).expect("confirmation overlay"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("Close workspace?")); + assert!(text.contains("1 pane")); + + let confirm = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &confirm.actions[..] else { + panic!("confirmation should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceClose(params) + if params.workspace_id == "ws_1" && params.close_group + )); +} diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs new file mode 100644 index 00000000..64cc78e6 --- /dev/null +++ b/src/client/shell/tests/copy.rs @@ -0,0 +1,1190 @@ +use super::*; + +#[test] +fn pasted_help_and_copy_queries_strip_control_characters() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: true, + scroll: 0, + })); + + assert!(state.insert_overlay_text("work\nspace")); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { ref query, .. })) + if query == "workspace" + )); + + state.overlay = None; + state.copy_mode = Some(ClientCopyModeState { + pane_id: "pane_1".into(), + content_revision: 0, + geometry: (80, 24), + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + offset_from_bottom: 0, + max_offset_from_bottom: 0, + entry_offset_from_bottom: 0, + selection: None, + search_prompt: Some(ClientCopySearchPrompt { + direction: crate::api::schema::PaneCopySearchDirection::Forward, + query: String::new(), + }), + search_query: String::new(), + search_direction: None, + search_matches: Vec::new(), + search_total: 0, + search_current: None, + search_current_global: None, + search_generation: 0, + copy_after_search: false, + }); + + assert!(state.insert_copy_search_text("needle\r\n")); + assert_eq!( + state + .copy_mode + .as_ref() + .and_then(|copy_mode| copy_mode.search_prompt.as_ref()) + .map(|prompt| prompt.query.as_str()), + Some("needle") + ); +} + +#[test] +fn client_mouse_selection_highlights_and_copies_through_endpoint_extraction() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + ) + )); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| !selection.is_visible())); + + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(drag.repaint); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); + let selected = state.compose(106, 20).expect("selected frame"); + let selected_cell = + &selected.cells[usize::from(pane.inner_rect.y) * 106 + usize::from(pane.inner_rect.x)]; + assert_ne!( + selected_cell.bg, + crate::protocol::color_to_u32(ratatui::style::Color::Reset) + ); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.selection.is_none()); + let [ClientShellAction::Endpoint { request, .. }] = &release.actions[..] else { + panic!("selection release should request endpoint extraction"); + }; + let request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.pane_id == "pane_1" + && params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 0 } + && params.cursor == crate::api::schema::PaneTextPoint { row: 0, col: 2 } + )); + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "LIV".into(), + }), + ); + assert!(repaint); + assert!(matches!( + &actions[..], + [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"LIV" + )); + assert_eq!( + state + .copy_feedback + .as_ref() + .map(|feedback| feedback.message.as_str()), + Some("copied to clipboard") + ); +} + +#[test] +fn clipboard_feedback_is_client_local_and_respects_config() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let now = std::time::Instant::now(); + assert!(state.show_copy_feedback(now)); + assert_eq!( + state + .copy_feedback + .as_ref() + .map(|feedback| feedback.message.as_str()), + Some("copied to clipboard") + ); + assert_eq!( + state.copy_feedback_deadline, + Some(now + std::time::Duration::from_secs(2)) + ); + + state.config.clipboard_toast_enabled = false; + state.copy_feedback = None; + state.copy_feedback_deadline = None; + assert!(!state.show_copy_feedback(now)); + assert!(state.copy_feedback.is_none()); + assert!(state.copy_feedback_deadline.is_none()); +} + +#[test] +fn retained_mouse_selection_copies_only_on_exact_copy_shortcut() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.copy_on_select = false; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + for event in [ + crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }, + crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }, + crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }, + ] { + state.handle_raw_events(vec![RawInputEvent::Mouse(event)]); + } + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_finalized)); + + let copy = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ))]); + assert!(state.selection.is_none()); + assert!(matches!( + ©.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + )); + assert!(copy.requests.is_empty()); + let request_id = match ©.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let (_, fallback) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: String::new(), + }), + ); + assert!(matches!( + &fallback[..], + [ClientShellAction::Request(ClientMessage::ClientShellPaneInput { + pane_id, + events, + })] if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + kind: crate::protocol::ClientKeyKind::Press, + .. + }, ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + ) + )); +} + +#[test] +fn selection_edge_drag_requests_scroll_and_timer_continues_it() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y.saturating_sub(1), + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &drag.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 3 + ) + )); + let drag_request_id = match &drag.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let now = std::time::Instant::now(); + state.selection_autoscroll_deadline = Some(now); + let tick = state.tick_selection_autoscroll(now); + assert!(tick.actions.is_empty()); + let (_, next_scroll) = + state.handle_endpoint_result("boot-1", &drag_request_id, Ok(pane_scroll_result(3, 20, 3))); + assert!(matches!( + &next_scroll[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 4 + ) + )); +} + +#[test] +fn keyboard_copy_mode_owns_cursor_selection_copy_and_scroll_restore() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + assert_eq!(state.mode, ClientShellMode::Copy); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(21) + ); + assert!(enter.actions.is_empty()); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('b'), + KeyModifiers::CONTROL, + ))]); + assert_eq!(state.mode, ClientShellMode::Prefix); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Copy); + + let page = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::PageUp, + KeyModifiers::empty(), + ))]); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(20) + ); + assert!(matches!( + &page.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 1 + ) + )); + let page_request_id = match &page.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + + let top = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('g'), + KeyModifiers::empty(), + ))]); + assert!(top.actions.is_empty()); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(0) + ); + let (_, top_actions) = + state.handle_endpoint_result("boot-1", &page_request_id, Ok(pane_scroll_result(1, 20, 2))); + let [ClientShellAction::Endpoint { request, .. }] = &top_actions[..] else { + panic!("latest queued scroll should follow the completed request"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" && params.offset_from_bottom == 20 + )); + let top_request_id = request.id.clone(); + state.handle_endpoint_result("boot-1", &top_request_id, Ok(pane_scroll_result(20, 20, 2))); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('v'), + KeyModifiers::empty(), + ))]); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('l'), + KeyModifiers::empty(), + ))]); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); + + let copy = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('y'), + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.copy_mode.is_none()); + assert!(state.selection.is_none()); + assert_eq!(copy.actions.len(), 2); + assert!(copy.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + ))); + assert!(copy.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 0 + ) + ))); +} + +#[test] +fn keyboard_copy_mode_content_motion_is_endpoint_backed_and_stale_safe() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + + let motion = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('w'), + KeyModifiers::empty(), + ))]); + let [ClientShellAction::Endpoint { request, .. }] = &motion.actions[..] else { + panic!("word motion should use endpoint semantics"); + }; + let request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneCopyMotion(params) + if params.cursor == origin + && params.motion == crate::api::schema::PaneCopyMotion::NextWordStart + )); + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: "pane_1".into(), + cursor: crate::api::schema::PaneTextPoint { + row: origin.row, + col: 3, + }, + content_revision: 0, + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.col), + Some(3) + ); +} + +#[test] +fn copy_search_owns_prompt_repeat_highlights_selection_and_restore() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('?'), + KeyModifiers::SHIFT, + ))]); + assert!(state.copy_mode.as_ref().is_some_and(|mode| { + mode.search_prompt.as_ref().is_some_and(|prompt| { + prompt.direction == crate::api::schema::PaneCopySearchDirection::Backward + }) + })); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state + .copy_mode + .as_ref() + .is_some_and(|mode| mode.search_prompt.is_none())); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('/'), + KeyModifiers::empty(), + ))]); + state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "junk", + ))]); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('u'), + KeyModifiers::CONTROL, + ))]); + state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "nee", + ))]); + state.handle_raw_events(vec![RawInputEvent::Paste("dleX".into())]); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Backspace, + KeyModifiers::empty(), + ))]); + assert_eq!( + state + .copy_mode + .as_ref() + .and_then(|mode| mode.search_prompt.as_ref()) + .map(|prompt| prompt.query.as_str()), + Some("needle") + ); + + let search = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Enter, + KeyModifiers::empty(), + ))]); + let [ClientShellAction::Endpoint { request, .. }] = &search.actions[..] else { + panic!("search should use endpoint terminal semantics"); + }; + let request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneCopySearch(params) + if params.pane_id == "pane_1" + && params.query == "needle" + && params.direction == crate::api::schema::PaneCopySearchDirection::Forward + && params.cursor == origin + && params.previous.is_none() + )); + let matches = vec![ + crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 5, col: 2 }, + end: crate::api::schema::PaneTextPoint { row: 5, col: 7 }, + }, + crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 15, col: 1 }, + end: crate::api::schema::PaneTextPoint { row: 15, col: 6 }, + }, + ]; + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(copy_search_result(matches.clone(), Some(0))), + ); + assert!(repaint); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(5) + ); + assert!(actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 15 + ) + ))); + let initial_scroll_id = actions + .iter() + .find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneScroll(_)) => + { + Some(request.id.clone()) + } + _ => None, + }) + .expect("initial search scroll"); + state.handle_endpoint_result( + "boot-1", + &initial_scroll_id, + Ok(pane_scroll_result(15, 20, 2)), + ); + let mut scrolled_surface = state.pane_surface.clone().expect("pane surface"); + scrolled_surface.panes[0] + .scroll + .as_mut() + .expect("scroll metrics") + .offset_from_bottom = 15; + state.set_pane_surface(scrolled_surface); + let frame = state.compose(106, 20).expect("search frame"); + let hit = state.hits.panes[0].clone(); + let viewport_top = 5u16; + let restored = frame.to_ratatui_buffer().expect("search frame buffer"); + let highlighted = restored + .cell((hit.inner_rect.x + 2, hit.inner_rect.y + (5 - viewport_top))) + .expect("highlighted search cell"); + assert_eq!(highlighted.bg, state.config.palette.accent); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('v'), + KeyModifiers::empty(), + ))]); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('n'), + KeyModifiers::empty(), + ))]); + let [ClientShellAction::Endpoint { request, .. }] = &repeat.actions[..] else { + panic!("repeat should use endpoint search"); + }; + let repeat_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneCopySearch(params) + if params.direction == crate::api::schema::PaneCopySearchDirection::Forward + && params.previous == Some(matches[0]) + )); + let (_, repeat_actions) = state.handle_endpoint_result( + "boot-1", + &repeat_id, + Ok(copy_search_result(matches.clone(), Some(1))), + ); + if let Some(scroll_id) = repeat_actions.iter().find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneScroll(_)) => + { + Some(request.id.clone()) + } + _ => None, + }) { + state.handle_endpoint_result("boot-1", &scroll_id, Ok(pane_scroll_result(6, 20, 2))); + } + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(15) + ); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); + + let reverse = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('N'), KeyModifiers::SHIFT), + )]); + let [ClientShellAction::Endpoint { request, .. }] = &reverse.actions[..] else { + panic!("reverse search should use endpoint search"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneCopySearch(params) + if params.direction == crate::api::schema::PaneCopySearchDirection::Backward + && params.previous == Some(matches[1]) + )); + let (_, reverse_actions) = state.handle_endpoint_result( + "boot-1", + &request.id, + Ok(copy_search_result(matches.clone(), Some(0))), + ); + if let Some(scroll_id) = reverse_actions.iter().find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneScroll(_)) => + { + Some(request.id.clone()) + } + _ => None, + }) { + state.handle_endpoint_result("boot-1", &scroll_id, Ok(pane_scroll_result(15, 20, 2))); + } + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state + .copy_mode + .as_ref() + .is_some_and(|mode| mode.search_query.is_empty() && mode.selection.is_none())); + let exit = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(exit.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 0 + ) + ))); +} + +#[test] +fn navigator_owns_search_mouse_selection_and_stable_target_focus() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenNavigator), + &mut open, + ); + let navigator = state.compose(106, 30).expect("navigator overlay"); + let navigator_text = navigator + .cells + .chunks(navigator.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(navigator_text.contains("client-shell")); + assert!(navigator_text.contains("pane 1")); + + let search = state.hits.navigator_search; + let focus_search = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: search.x, + row: search.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(focus_search.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + search_focused: true, + .. + })) + )); + assert!(state.handle_input_bytes(b"client").actions.is_empty()); + let filtered = state.compose(106, 30).expect("filtered navigator"); + assert!(filtered + .cursor + .as_ref() + .is_some_and(|cursor| cursor.visible)); + + state.handle_input_bytes(b"\x1b"); + state.handle_input_bytes(b"a"); + state.compose(106, 30).expect("navigator rows"); + let pane_index = { + let snapshot = state.snapshot.as_deref().expect("snapshot"); + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + render::client_navigator_rows(snapshot, navigator) + .iter() + .position(|row| matches!(row.target, ClientNavigatorTarget::Pane(_))) + .expect("pane row") + }; + let pane_rect = state + .hits + .navigator_rows + .iter() + .find(|(_, index)| *index == pane_index) + .map(|(rect, _)| *rect) + .expect("visible pane row"); + let select = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Moved, + column: pane_rect.x + 6, + row: pane_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(select.repaint); + let accept = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane_rect.x + 6, + row: pane_rect.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &accept.actions[..] else { + panic!("navigator pane click should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + )); + assert!(state.overlay.is_none()); +} + +#[test] +fn copy_mode_survives_mouse_motion_and_parks_across_focus_changes() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 10, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Moved, + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state.copy_mode.is_some()); + + state.handle_input_bytes(b"v"); + assert!(state + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.selection.is_some())); + + let mut unfocused = snapshot(); + unfocused.focused_pane_id = Some("pane_2".into()); + unfocused.panes[0].focused = false; + unfocused.panes.push(ClientShellPane { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + label: None, + cwd: Some("/repo".into()), + foreground_cwd: Some("/repo".into()), + focused: true, + right_click_passthrough: false, + }); + state.set_snapshot(Box::new(unfocused.clone())); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.selection.is_some())); + + let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix; + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + prefix_key, + prefix_modifiers, + ))]); + state.set_snapshot(Box::new(unfocused.clone())); + assert_eq!(state.mode, ClientShellMode::Prefix); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Terminal); + + let mut other_selection = + crate::selection::Selection::absolute_range("pane_2".to_owned(), (0, 0), (0, 1)); + assert!(other_selection.finish()); + state.selection = Some(other_selection); + state.set_snapshot(Box::new(unfocused)); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_2")); + + state.set_snapshot(Box::new(snapshot())); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state.copy_mode.is_some()); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_1")); + state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "ignored", + ))]); + state.handle_raw_events(vec![RawInputEvent::Paste("ignored".into())]); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_1")); + + state.mode = ClientShellMode::Navigate; + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_1")); + state.mode = ClientShellMode::Resize; + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_1")); +} + +#[test] +fn retained_selection_copy_suppresses_key_repeats() { + let mut config = Config::default(); + config.ui.copy_on_select = false; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut selection = + crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1)); + assert!(selection.finish()); + state.selection = Some(selection); + + let key = crate::input::TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + let press = state.handle_raw_events(vec![RawInputEvent::Key(key.clone())]); + assert!(press.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + ))); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key( + key.with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeat.actions.is_empty()); + assert!(repeat.requests.is_empty()); +} + +#[test] +fn rapid_copy_motions_are_chained_from_the_previous_result() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + + let first = state.handle_input_bytes(b"w"); + let second = state.handle_input_bytes(b"w"); + assert_eq!(first.actions.len(), 1); + assert!(second.actions.is_empty()); + let first_id = match &first.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let intermediate = crate::api::schema::PaneTextPoint { + row: origin.row, + col: 2, + }; + let (_, follow_up) = state.handle_endpoint_result( + "boot-1", + &first_id, + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: "pane_1".into(), + cursor: intermediate, + content_revision: 0, + }), + ); + assert!(matches!( + &follow_up[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneCopyMotion(params) + if params.cursor == intermediate + ) + )); +} + +#[test] +fn queued_copy_keys_preserve_prefix_order() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 10, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + let motion = state.handle_input_bytes(b"w"); + state.handle_input_bytes(b"l"); + let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix; + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + prefix_key, + prefix_modifiers, + ))]); + let motion_id = match &motion.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + state.handle_endpoint_result( + "boot-1", + &motion_id, + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: "pane_1".into(), + cursor: origin, + content_revision: 0, + }), + ); + assert_eq!(state.mode, ClientShellMode::Prefix); + assert_eq!( + state + .copy_mode + .as_ref() + .map(|copy_mode| copy_mode.cursor.col), + Some(origin.col.saturating_add(1)) + ); +} + +#[test] +fn reentering_copy_mode_on_the_same_pane_is_a_no_op() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 10, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut first = ClientShellInput::default(); + assert!(state.enter_copy_mode(&mut first)); + state + .copy_mode + .as_mut() + .expect("copy mode") + .offset_from_bottom = 10; + let mut reenter = ClientShellInput::default(); + assert!(state.enter_copy_mode(&mut reenter)); + assert!(reenter.actions.is_empty()); + assert_eq!( + state + .copy_mode + .as_ref() + .map(|copy_mode| copy_mode.entry_offset_from_bottom), + Some(0) + ); +} + +#[test] +fn copy_waits_for_endpoint_motion_before_copying_selection() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + state.handle_input_bytes(b"v"); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + let motion = state.handle_input_bytes(b"w"); + let queued_copy = state.handle_input_bytes(b"y"); + assert!(queued_copy.actions.is_empty()); + let motion_id = match &motion.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let target = crate::api::schema::PaneTextPoint { + row: origin.row, + col: 2, + }; + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &motion_id, + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: "pane_1".into(), + cursor: target, + content_revision: 0, + }), + ); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor == origin && params.cursor == target + ) + ))); +} + +#[test] +fn new_content_revision_invalidates_copy_search_coordinates() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface.clone()); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let copy_mode = state.copy_mode.as_mut().expect("copy mode"); + copy_mode.search_query = "needle".into(); + copy_mode + .search_matches + .push(crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + end: crate::api::schema::PaneTextPoint { row: 0, col: 1 }, + }); + copy_mode.search_total = 1; + copy_mode.search_current = Some(0); + copy_mode.search_current_global = Some(0); + + pane_surface.panes[0].content_revision = 2; + state.set_pane_surface(pane_surface); + let copy_mode = state.copy_mode.as_ref().expect("copy mode retained"); + assert!(copy_mode.search_matches.is_empty()); + assert_eq!(copy_mode.search_total, 0); + assert_eq!(copy_mode.search_current, None); +} + +#[test] +fn word_selection_result_survives_focus_snapshot_lag() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let hit = state.hits.panes[0].clone(); + let mut request = ClientShellInput::default(); + state.request_word_selection(&hit, 0, 1, &mut request); + let request_id = match &request.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let mut lagging = snapshot(); + lagging.focused_pane_id = None; + lagging.panes[0].focused = false; + state.set_snapshot(Box::new(lagging)); + let (repaint, _) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "hello world".into(), + }), + ); + assert!(repaint); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); +} diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs new file mode 100644 index 00000000..bc72b01a --- /dev/null +++ b/src/client/shell/tests/input.rs @@ -0,0 +1,609 @@ +use super::*; + +#[test] +fn host_appearance_prefers_explicit_reports_over_background_inference() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.theme_runtime.auto_switch = true; + + let light = crate::app::client_palette_for_appearance( + &state.config.theme_runtime, + crate::terminal_theme::HostAppearance::Light, + ); + let dark = crate::app::client_palette_for_appearance( + &state.config.theme_runtime, + crate::terminal_theme::HostAppearance::Dark, + ); + + let inferred = state.handle_raw_events(vec![RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Background, + color: crate::terminal_theme::RgbColor { + r: 255, + g: 255, + b: 255, + }, + }]); + assert!(inferred.repaint); + assert!(matches!( + inferred.requests.as_slice(), + [ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::DefaultColor { + kind: crate::protocol::ClientHostDefaultColorKind::Background, + .. + } + }] + )); + assert_eq!( + state.host_appearance, + Some(crate::terminal_theme::HostAppearance::Light) + ); + assert!(!state.host_appearance_explicit); + assert_eq!(state.config.palette, light); + + let explicit = state.handle_raw_events(vec![RawInputEvent::HostColorSchemeChanged( + crate::terminal_theme::HostAppearance::Dark, + )]); + assert!(explicit.repaint); + assert!(explicit.query_host_theme); + assert!(matches!( + explicit.requests.as_slice(), + [ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::Appearance( + crate::protocol::ClientHostAppearance::Dark + ) + }] + )); + assert_eq!( + state.host_appearance, + Some(crate::terminal_theme::HostAppearance::Dark) + ); + assert!(state.host_appearance_explicit); + assert_eq!(state.config.palette, dark); + + let ignored = state.handle_raw_events(vec![RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Background, + color: crate::terminal_theme::RgbColor { + r: 255, + g: 255, + b: 255, + }, + }]); + assert!(!ignored.repaint); + assert_eq!(ignored.requests.len(), 1); + assert_eq!( + state.host_appearance, + Some(crate::terminal_theme::HostAppearance::Dark) + ); + assert_eq!(state.config.palette, dark); +} + +#[test] +fn modal_paste_shortcut_modifiers_are_platform_specific() { + let key = |code, modifiers| crate::input::TerminalKey::new(code, modifiers); + + assert!(input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::CONTROL), + false + )); + assert!(input::is_modal_paste_shortcut_for_platform( + &key( + KeyCode::Char('V'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT + ), + false + )); + assert!(!input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::SUPER), + false + )); + assert!(input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::CONTROL), + true + )); + assert!(input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::SUPER), + true + )); + assert!(!input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::ALT), + true + )); +} + +#[test] +fn modal_paste_inserts_clipboard_text_through_overlay_text_path() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: "replace me".into(), + replace_on_type: true, + target: ClientRenameTarget::Pane { + pane_id: "pane_1".into(), + }, + })); + let mut outcome = ClientShellInput::default(); + let key = crate::input::TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL); + + assert!( + state.handle_modal_paste_shortcut_with(&key, &mut outcome, || { + Some("feature/pasted".into()) + }) + ); + assert!(outcome.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { ref input, replace_on_type: false, .. })) + if input == "feature/pasted" + )); +} + +#[test] +fn client_shell_graphics_follow_final_shell_origin_and_local_overlay_visibility() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + let key = crate::protocol::SurfaceGraphicsAssetKey { + source: crate::protocol::SurfaceGraphicsSource::Terminal { + target: crate::protocol::SurfaceGraphicsTarget::Pane { + pane_id: "pane_1".into(), + }, + image_id: 1, + }, + image_width: 1, + image_height: 1, + format: crate::protocol::SurfaceGraphicsFormat::Rgba, + data_len: 4, + data_fingerprint: 17, + }; + pane_surface.graphics = crate::protocol::SurfaceGraphicsScene { + assets: vec![crate::protocol::SurfaceGraphicsAsset { + key: key.clone(), + data: vec![1, 2, 3, 4], + }], + placements: vec![crate::protocol::SurfaceGraphicsPlacement { + asset: key, + logical_placement_id: 1, + x: 0, + y: 0, + cols: 1, + rows: 1, + source_x: 0, + source_y: 0, + source_width: 1, + source_height: 1, + x_offset: 0, + y_offset: 0, + z: 0, + scrollback_offset: 0, + }], + retained_assets: Vec::new(), + }; + state.set_pane_surface(pane_surface); + + let visible = state.compose(106, 20).expect("visible graphics frame"); + let visible = String::from_utf8_lossy(&visible.graphics); + assert!(visible.contains("a=t,t=d")); + assert!(visible.contains("\u{1b}[2;27H")); + + state.overlay = Some(ClientShellOverlay::Onboarding); + let hidden = state.compose(106, 20).expect("overlay frame"); + assert!(String::from_utf8_lossy(&hidden.graphics).contains("a=d,d=i")); + + state.overlay = None; + let restored = state.compose(106, 20).expect("restored graphics frame"); + let restored = String::from_utf8_lossy(&restored.graphics); + assert!(restored.contains("a=p")); + assert!(!restored.contains("a=t,t=d")); +} + +#[test] +fn delayed_link_fallback_does_not_replay_against_changed_geometry() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::CONTROL, + }; + let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + let request_id = match &activate.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + _ => panic!("expected link activation request"), + }; + state.hits.panes[0].inner_rect.x = state.hits.panes[0].inner_rect.x.saturating_add(1); + + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: None, + handled: false, + }), + ); + + assert!(actions.is_empty()); + assert!(state.url_click_consumes_until_up); +} + +#[test] +fn invalid_experimental_reload_keeps_input_source_preference() { + let mut shell = ClientShellConfig::from_config(&Config::default()); + shell.switch_ascii_input_source_in_prefix = true; + let config = Config::default(); + shell.apply_live_config(&config, &[], &["experimental".to_owned()]); + assert!(shell.switch_ascii_input_source_in_prefix); + shell.apply_live_config(&config, &[], &[]); + assert!(!shell.switch_ascii_input_source_in_prefix); +} + +#[test] +fn physical_release_uses_the_leased_press_code_with_current_modifiers() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let press = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x58, + virtual_scan_code: 0x2d, + unicode: 0, + control_key_state: 0, + }); + state.handle_raw_events(vec![RawInputEvent::Key(press)]); + let release = crate::input::TerminalKey::new(KeyCode::Char('z'), KeyModifiers::SHIFT) + .with_kind(crossterm::event::KeyEventKind::Release) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: false, + repeat_count: 1, + virtual_key_code: 0x5a, + virtual_scan_code: 0x2d, + unicode: 0, + control_key_state: 0x0010, + }); + + let outcome = state.handle_raw_events(vec![RawInputEvent::Key(release)]); + + assert!(matches!( + &outcome.requests[..], + [ClientMessage::ClientShellPaneInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers, + kind: crate::protocol::ClientKeyKind::Release, + physical_key_id: Some(0x2d), + .. + }] if *modifiers == KeyModifiers::SHIFT.bits() + ) + )); +} + +#[test] +fn highlighted_search_match_copies_after_in_flight_repeat() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let matches = vec![ + crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 5, col: 2 }, + end: crate::api::schema::PaneTextPoint { row: 5, col: 7 }, + }, + crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 15, col: 1 }, + end: crate::api::schema::PaneTextPoint { row: 15, col: 6 }, + }, + ]; + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('/'), + KeyModifiers::empty(), + ))]); + state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "needle", + ))]); + let initial = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Enter, KeyModifiers::empty()), + )]); + let [ClientShellAction::Endpoint { request, .. }] = &initial.actions[..] else { + panic!("initial search request"); + }; + state.handle_endpoint_result( + "boot-1", + &request.id, + Ok(copy_search_result(matches.clone(), Some(0))), + ); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('n'), + KeyModifiers::empty(), + ))]); + let [ClientShellAction::Endpoint { request, .. }] = &repeat.actions[..] else { + panic!("repeat search request"); + }; + let repeat_id = request.id.clone(); + + let early_copy = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()), + )]); + assert!(early_copy.actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Copy); + + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &repeat_id, + Ok(copy_search_result(matches, Some(1))), + ); + assert_eq!(state.mode, ClientShellMode::Terminal); + let selection_request_id = actions + .iter() + .find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!( + request.method, + crate::api::schema::Method::PaneSelectionRead(_) + ) => + { + Some(request.id.clone()) + } + _ => None, + }) + .expect("deferred selection read"); + let (_, clipboard) = state.handle_endpoint_result( + "boot-1", + &selection_request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "needle".into(), + }), + ); + assert!(matches!( + &clipboard[..], + [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"needle" + )); +} + +#[test] +fn pixel_host_reports_use_cells_without_target_pixel_mode_and_release_outside() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let geometry = + crate::input::mouse::HostGeometry::new(106, 20, 1060, 400).expect("host geometry"); + let x = u32::from(pane.inner_rect.x) * 10 + 21; + let y = u32::from(pane.inner_rect.y) * 20 + 21; + + let down = state.handle_pixel_mouse(format!("\x1b[<0;{x};{y}M").as_bytes(), geometry); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + position: ClientMousePosition::Cell { column: 2, row: 1 }, + .. + }] + ) + )); + + state.hits.panes.clear(); + let release = state.handle_pixel_mouse(b"\x1b[<0;1;1m", geometry); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Cell { .. }, + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn shell_targets_unconsumed_input_and_keeps_prefix_local() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + + let text = state.handle_input_bytes(b"hello"); + assert_eq!(text.requests.len(), 1); + let ClientMessage::ClientShellPaneInput { pane_id, events } = &text.requests[0] else { + panic!("expected targeted pane input"); + }; + assert_eq!(pane_id, "pane_1"); + assert_eq!(events.len(), 5); + assert!(matches!( + &events[0], + ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('h'), + generated_text: Some(text), + .. + } if text == "h" + )); + + let interrupt = state.handle_input_bytes(b"\x1b[99;5u"); + assert_eq!(interrupt.requests.len(), 1); + let ClientMessage::ClientShellPaneInput { events, .. } = &interrupt.requests[0] else { + panic!("expected semantic interrupt"); + }; + assert!(matches!( + &events[..], + [ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + modifiers, + kind: crate::protocol::ClientKeyKind::Press, + .. + }] if *modifiers == KeyModifiers::CONTROL.bits() + )); + + let alt = state.handle_input_bytes(b"\x1b[120;3u"); + let ClientMessage::ClientShellPaneInput { events, .. } = &alt.requests[0] else { + panic!("expected semantic alt key"); + }; + assert!(matches!( + &events[..], + [ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers, + .. + }] if *modifiers == KeyModifiers::ALT.bits() + )); + assert!(!state.handle_input_bytes(&[0x02]).detach); + let detach = state.handle_input_bytes(b"q"); + assert!(detach.detach); + assert!(detach.requests.is_empty()); +} + +#[test] +fn pane_key_release_keeps_the_press_target() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + + let press = state.handle_input_bytes(b"\x1b[99;5u"); + let release = state.handle_input_bytes(b"\x1b[99;5:3u"); + let ClientMessage::ClientShellPaneInput { + pane_id: press_target, + .. + } = &press.requests[0] + else { + panic!("expected targeted press"); + }; + let ClientMessage::ClientShellPaneInput { + pane_id: release_target, + events, + } = &release.requests[0] + else { + panic!("expected targeted release"); + }; + assert_eq!(release_target, press_target); + assert!(matches!( + &events[..], + [ClientPaneInputEvent::Key { + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + )); +} + +#[test] +fn help_overlay_uses_live_keymap_and_owns_filter_state() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help), + &mut open, + ); + let initial = state.compose(106, 30).expect("help overlay"); + let text = initial + .cells + .chunks(initial.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("keybinds")); + assert!(text.contains("prefix mode")); + + assert!(state.handle_input_bytes(b"/").actions.is_empty()); + assert!(state.handle_input_bytes(b"workspace").actions.is_empty()); + let filtered = state.compose(106, 30).expect("filtered help"); + let text = filtered + .cells + .chunks(filtered.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("workspace navigation")); + assert!(!text.contains("prefix mode")); + assert!(filtered + .cursor + .as_ref() + .is_some_and(|cursor| cursor.visible)); + + assert!(state.handle_input_bytes(b"\x1b").repaint); + assert!(matches!(state.overlay, Some(ClientShellOverlay::Help(_)))); + assert!(state.handle_input_bytes(b"\x1b").repaint); + assert!(state.overlay.is_none()); +} + +#[test] +fn rename_pane_empty_value_is_preserved_as_a_clear_request() { + let mut snapshot = snapshot(); + snapshot.panes[0].label = Some("build".into()); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::RenamePane), + &mut open, + ); + assert!(state.handle_input_bytes(&[0x15]).actions.is_empty()); + let save = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &save.actions[..] else { + panic!("pane rename should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneRename(params) + if params.pane_id == "pane_1" && params.label.as_deref() == Some("") + )); +} + +#[test] +fn styled_client_composition_preserves_pane_hyperlinks() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + let linked = Buffer::with_lines(["LIVE", "PANE"]); + pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &linked, + None, + &[((0, 0), "L".into(), "https://example.test".into())], + ); + state.set_pane_surface(pane_surface); + let mut selection = + crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1)); + assert!(selection.finish()); + state.selection = Some(selection); + let frame = state.compose(106, 20).expect("composed frame"); + let hit = &state.hits.panes[0]; + let index = + usize::from(hit.inner_rect.y) * usize::from(frame.width) + usize::from(hit.inner_rect.x); + let link = frame.cells[index].hyperlink.expect("linked cell") as usize; + assert_eq!(frame.hyperlinks[link], "https://example.test"); +} diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs new file mode 100644 index 00000000..5d2d8243 --- /dev/null +++ b/src/client/shell/tests/keybindings_settings.rs @@ -0,0 +1,633 @@ +use super::*; + +#[test] +fn shell_new_controls_use_the_same_client_action_routes_as_keybinds() { + let mut config = Config::default(); + config.ui.prompt_new_workspace_name = false; + config.ui.prompt_new_tab_name = true; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + + let new_workspace = state.hits.new_workspace; + let create_workspace = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: new_workspace.x + 1, + row: new_workspace.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &create_workspace.actions[..] else { + panic!("new workspace click should use the endpoint API"); + }; + assert!(matches!( + request.method, + crate::api::schema::Method::WorkspaceCreate(_) + )); + + let new_tab = state.hits.new_tab; + let open_new_tab = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: new_tab.x + 1, + row: new_tab.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(open_new_tab.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::NewTab { .. }, + .. + })) + )); +} + +#[test] +fn manual_client_chrome_preferences_round_trip_per_endpoint() { + let path = std::env::temp_dir().join(format!( + "herdr-client-shell-prefs-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let mut state = ClientShellState::new(config); + state.sidebar_width = 31; + state.sidebar_width_manual = true; + state.sidebar_section_split = 0.7; + state.sidebar_section_split_manual = true; + state.sidebar_collapsed = true; + state.sidebar_collapsed_manual = true; + state.collapsed_groups.insert("repo-two".into()); + state.collapsed_groups.insert("repo-one".into()); + state.persist_chrome_preferences(&mut ClientShellInput::default()); + + let reloaded_config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let reloaded = ClientShellState::new(reloaded_config); + assert_eq!(reloaded.sidebar_width, 31); + assert!(reloaded.sidebar_width_manual); + assert_eq!(reloaded.sidebar_section_split, 0.7); + assert!(reloaded.sidebar_section_split_manual); + assert!(reloaded.sidebar_collapsed); + assert!(reloaded.sidebar_collapsed_manual); + assert_eq!( + reloaded.collapsed_groups, + HashSet::from(["repo-one".to_string(), "repo-two".to_string()]) + ); + std::fs::remove_file(path).expect("remove client chrome preferences"); +} + +#[test] +fn tab_bar_renders_endpoint_status_ellipses_and_clamps_to_useful_scroll() { + let mut projected = snapshot(); + projected.tab_bar_right = vec![ + crate::protocol::ClientShellTabStatusSegment { + text: "ZOOM".into(), + accent: true, + }, + crate::protocol::ClientShellTabStatusSegment { + text: "host".into(), + accent: false, + }, + ]; + projected.tab_bar_right_separator = " · ".into(); + for number in 2..=8 { + projected.tabs.push(ClientShellTab { + tab_id: format!("tab_{number}"), + workspace_id: "ws_1".into(), + number, + label: number.to_string(), + custom_label: false, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + let mut config = ClientShellConfig::from_config(&Config::default()); + config.mobile_width_threshold = 0; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + let frame = state.compose(106, 20).expect("status and overflow tabs"); + let top = frame.cells[..frame.width as usize] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(top.contains("ZOOM · host")); + assert!(top.contains('…')); + + state.tab_scroll = usize::MAX; + state.reveal_focused_tab = false; + state.compose(106, 20).expect("clamped tab scroll"); + assert!(state.tab_scroll < 7); + let manual_scroll = state.tab_scroll; + let mut replacement = (**state.snapshot.as_ref().expect("snapshot")).clone(); + replacement.revision = 2; + replacement.tab_bar_right[1].text = "tick".into(); + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + state.set_snapshot(Box::new(replacement)); + state.set_pane_surface(replacement_surface); + assert!(!state.reveal_focused_tab); + state.compose(106, 20).expect("same-width status update"); + assert_eq!(state.tab_scroll, manual_scroll); + + state.compose(45, 20).expect("narrow tabs win over status"); + let narrow = state.compose(45, 20).expect("narrow tab frame"); + let top = narrow.cells[..narrow.width as usize] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(!top.contains("ZOOM · host")); +} + +#[test] +fn configured_prefix_is_client_owned_and_renders_its_bar() { + let config = toml::from_str::<Config>( + r#" +[keys] +prefix = "ctrl+a" +detach = "prefix+x" +"#, + ) + .expect("configured keybinds"); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let old_default = state.handle_input_bytes(&[0x02]); + assert_eq!( + old_default.requests.len(), + 1, + "ctrl-b should reach the pane" + ); + + let prefix = state.handle_input_bytes(&[0x01]); + assert!(prefix.requests.is_empty()); + assert!(prefix.repaint); + let frame = state.compose(106, 20).expect("prefix frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("PREFIX"), "frame: {text:?}"); + assert!(text.contains("ctrl+a"), "frame: {text:?}"); + + let detach = state.handle_input_bytes(b"x"); + assert!(detach.detach); + assert!(detach.requests.is_empty()); +} + +#[test] +fn prefix_endpoint_action_uses_public_api_with_stable_ids() { + let mut config = Config::default(); + config.ui.prompt_new_tab_name = false; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + + assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); + let create = state.handle_input_bytes(b"c"); + let [ClientShellAction::Endpoint { boot_id, request }] = &create.actions[..] else { + panic!("expected one endpoint action: {:?}", create.actions); + }; + assert_eq!(boot_id, "boot-1"); + match &request.method { + crate::api::schema::Method::TabCreate(params) => { + assert_eq!(params.workspace_id.as_deref(), Some("ws_1")); + assert!(params.focus); + } + other => panic!("expected tab.create, got {other:?}"), + } + assert!(state.pending_requests.contains_key(&request.id)); +} + +#[test] +fn remote_keybinding_sources_keep_local_commands_off_endpoints_and_apply_server_profiles() { + let local: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+a" +new_tab = "prefix+c" + +[[keys.command]] +key = "prefix+c" +command = "local-only" +"#, + ) + .unwrap(); + let remote_local = ClientShellConfig::from_config(&local) + .with_keybinding_source(ClientShellKeybindingSource::RemoteLocal); + assert_eq!(remote_local.keybinds.prefix.0, KeyCode::Char('a')); + assert!(remote_local.keybinds.keybinds.custom_commands.is_empty()); + assert_eq!( + remote_local.keybinds.keybinds.new_tab.label().as_deref(), + Some("prefix+c") + ); + + let mut local_state = ClientShellState::new( + ClientShellConfig::from_config(&local) + .with_keybinding_source(ClientShellKeybindingSource::Local), + ); + let mut local_projection = snapshot(); + local_projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_loaded_endpoint".into(), + binding_label: "prefix+c / prefix+y".into(), + binding_labels: vec!["prefix+c".into(), "prefix+y".into()], + action: crate::protocol::ClientShellCommandAction::Shell, + description: Some("loaded endpoint command".into()), + }); + local_state.set_snapshot(Box::new(local_projection)); + assert_eq!( + local_state.config.keybinds.keybinds.custom_commands[0].label, + "prefix+y" + ); + assert_eq!( + local_state + .config + .keybinds + .keybinds + .new_tab + .label() + .as_deref(), + Some("prefix+c") + ); + let mut command_outcome = ClientShellInput::default(); + local_state.record_binding( + crate::input::KeybindMatch::Command( + local_state.config.keybinds.keybinds.custom_commands[0].clone(), + ), + &mut command_outcome, + ); + let [ClientShellAction::Endpoint { request, .. }] = &command_outcome.actions[..] else { + panic!("expected surviving endpoint command binding"); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command invocation"); + }; + assert_eq!(params.command_id, "cmd_loaded_endpoint"); + + let mut id_only_projection = snapshot(); + id_only_projection.revision = 2; + id_only_projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_reloaded_endpoint".into(), + binding_label: "prefix+c / prefix+y".into(), + binding_labels: vec!["prefix+c".into(), "prefix+y".into()], + action: crate::protocol::ClientShellCommandAction::Shell, + description: Some("loaded endpoint command".into()), + }); + local_state.mode = ClientShellMode::Prefix; + local_state.set_snapshot(Box::new(id_only_projection)); + assert_eq!(local_state.mode, ClientShellMode::Prefix); + assert_eq!( + local_state.config.keybinds.keybinds.custom_commands[0].command, + "cmd_reloaded_endpoint" + ); + + let endpoint: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+x" +new_tab = "prefix+n" +"#, + ) + .unwrap(); + let mut state = ClientShellState::new( + ClientShellConfig::from_config(&local) + .with_keybinding_source(ClientShellKeybindingSource::Endpoint), + ); + let mut projection = snapshot(); + projection.server_keybindings_toml = endpoint.local_keybindings_profile_toml().ok(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_remote".into(), + binding_label: "prefix+z".into(), + binding_labels: vec!["prefix+z".into()], + action: crate::protocol::ClientShellCommandAction::Shell, + description: Some("remote command".into()), + }); + state.set_snapshot(Box::new(projection)); + + assert_eq!(state.config.keybinds.prefix.0, KeyCode::Char('x')); + assert_eq!( + state.config.keybinds.keybinds.new_tab.label().as_deref(), + Some("prefix+n") + ); + assert_eq!( + state.config.keybinds.keybinds.custom_commands[0].label, + "prefix+z" + ); + assert_eq!( + state.config.keybinds.keybinds.custom_commands[0] + .description + .as_deref(), + Some("remote command") + ); + assert_eq!( + state.config.keybinds.keybinds.custom_commands[0].command, + "cmd_remote" + ); +} + +#[test] +fn custom_binding_invokes_only_the_endpoint_manifest_id() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("z"), + label: "prefix+z".into(), + command: "secret-command --token hidden".into(), + action: crate::config::CustomCommandAction::Shell, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_0123456789abcdef0123456789abcdef".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::Shell, + description: None, + }); + state.set_snapshot(Box::new(projection)); + + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected endpoint command invocation"); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command.invoke"); + }; + assert_eq!(params.command_id, "cmd_0123456789abcdef0123456789abcdef"); + assert_eq!(params.workspace_id.as_deref(), Some("ws_1")); + assert_eq!(params.tab_id.as_deref(), Some("tab_1")); + assert_eq!(params.pane_id.as_deref(), Some("pane_1")); + assert_eq!(params.selection, None); + assert!(!serde_json::to_string(request) + .unwrap() + .contains("secret-command")); +} + +#[test] +fn plugin_command_carries_client_owned_selection_coordinates() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("p"), + label: "prefix+p".into(), + command: "plugin.action".into(), + action: crate::config::CustomCommandAction::PluginAction, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_plugin".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::PluginAction, + description: None, + }); + state.set_snapshot(Box::new(projection)); + let mut pane_surface = surface(); + pane_surface.panes[0].content_revision = 42; + state.set_pane_surface(pane_surface); + let mut selection = + crate::selection::Selection::absolute_range("pane_1".to_owned(), (2, 3), (4, 5)); + assert!(selection.finish()); + state.selection = Some(selection); + + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected endpoint command invocation"); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command.invoke"); + }; + assert_eq!( + params.selection, + Some(crate::api::schema::PaneSelectionReadParams { + pane_id: "pane_1".into(), + anchor: crate::api::schema::PaneTextPoint { row: 2, col: 3 }, + cursor: crate::api::schema::PaneTextPoint { row: 4, col: 5 }, + content_revision: Some(42), + }) + ); +} + +#[test] +fn generic_endpoint_failures_and_control_errors_are_visible() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut outcome = ClientShellInput::default(); + state.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: "missing".into(), + }), + &mut outcome, + ); + let request_id = match &outcome.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected generic endpoint request, got {other:?}"), + }; + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("not_found".into()), + message: "workspace no longer exists".into(), + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert_eq!( + state.endpoint_error.as_deref(), + Some("workspace no longer exists") + ); + + assert!(state.receive_endpoint_error("Paste rejected: too large".into())); + assert_eq!( + state.endpoint_error.as_deref(), + Some("Paste rejected: too large") + ); + assert!(!state.receive_endpoint_error("Paste rejected: too large".into())); +} + +#[test] +fn custom_binding_missing_from_endpoint_manifest_is_not_forwarded() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("z"), + label: "prefix+z".into(), + command: "secret-command".into(), + action: crate::config::CustomCommandAction::Shell, + description: None, + width: None, + height: None, + }; + + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + + assert!(outcome.actions.is_empty()); + assert!(outcome.repaint); + assert!(state + .endpoint_error + .as_deref() + .is_some_and(|error| error.contains("not available"))); +} + +#[test] +fn help_overlay_restores_released_search_scroll_and_custom_binding_behavior() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "plugin-action".into(), + binding_label: "prefix+z".into(), + binding_labels: vec!["prefix+z".into()], + action: crate::protocol::ClientShellCommandAction::PluginAction, + description: Some("run plugin action".into()), + }); + state.set_snapshot(Box::new(projection)); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help), + &mut open, + ); + let initial = state.compose(106, 30).expect("help overlay"); + let text = initial + .cells + .chunks(initial.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("global")); + assert!(state.hits.help_max_scroll > 0); + assert_ne!(state.hits.help_scrollbar, Rect::default()); + + state.handle_input_bytes(b"/"); + state.handle_input_bytes(b"plugin"); + let custom = state.compose(106, 30).expect("custom help search"); + let text = custom + .cells + .chunks(custom.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("custom")); + assert!(text.contains("run plugin action")); + state.handle_input_bytes(b"\x1b"); + + state.handle_input_bytes(b"/"); + state.handle_input_bytes(b"does-not-exist"); + let empty = state.compose(106, 30).expect("empty help search"); + let text = empty + .cells + .chunks(empty.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("no matching keybinds")); + + state.handle_input_bytes(b"\x1b"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: false, + ref query, + scroll: 0, + })) if query.is_empty() + )); + state.compose(106, 30).expect("restored help"); + state.handle_input_bytes(b"\x1b[F"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { scroll, .. })) + if scroll == state.hits.help_max_scroll + )); + state.handle_input_bytes(b"\x1b[H"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + scroll: 0, + .. + })) + )); + state.handle_input_bytes(b"?"); + assert!(state.overlay.is_none()); +} + +#[test] +fn resize_mode_reuses_endpoint_resize_and_stays_active_until_done() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); + assert!(state.handle_input_bytes(b"r").actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Resize); + + let modified = state.handle_input_bytes(b"\x1b[1;2D"); + assert!(matches!( + &modified.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneResize(params) + if params.direction == crate::api::schema::PaneDirection::Left + ) + )); + assert_eq!(state.mode, ClientShellMode::Resize); + + let resize = state.handle_input_bytes(b"h"); + let [ClientShellAction::Endpoint { request, .. }] = &resize.actions[..] else { + panic!("resize should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneResize(params) + if params.pane_id.as_deref() == Some("pane_1") + && params.direction == crate::api::schema::PaneDirection::Left + )); + assert_eq!(state.mode, ClientShellMode::Resize); + + assert!(state.handle_input_bytes(b"\r").actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Terminal); +} diff --git a/src/client/shell/tests/mobile.rs b/src/client/shell/tests/mobile.rs new file mode 100644 index 00000000..094b141f --- /dev/null +++ b/src/client/shell/tests/mobile.rs @@ -0,0 +1,559 @@ +use super::*; + +#[test] +fn navigate_update_status_uses_released_desktop_and_mobile_placement() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.tab_bar_position = crate::config::TabBarPositionConfig::Bottom; + config.hide_tab_bar_when_single_tab = false; + let mut state = ClientShellState::new(config); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + + let bottom = state.compose(106, 30).expect("bottom-tab update shell"); + let row_text = |frame: &FrameData, row: u16| { + let width = usize::from(frame.width); + let start = usize::from(row) * width; + frame.cells[start..start + width] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }; + assert!(row_text(&bottom, 29).contains("update ready")); + assert!(!row_text(&bottom, 28).contains("update ready")); + assert!(state.hits.tabs.is_empty()); + assert!(state.hits.new_tab.is_empty()); + assert!(state.hits.tab_scroll_left.is_empty()); + assert!(state.hits.tab_scroll_right.is_empty()); + + state.config.tab_bar_position = crate::config::TabBarPositionConfig::Top; + state.visible_notification = Some(ClientVisibleNotification { + event: SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "bottom notification".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: Some(crate::config::ToastHerdrPosition::BottomRight), + }, + deadline: std::time::Instant::now(), + }); + let top = state.compose(106, 30).expect("top-tab update shell"); + assert!(row_text(&top, 29).contains("update ready")); + + let mobile = state.compose(44, 30).expect("mobile update shell"); + let mobile_text = mobile + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(mobile_text.contains("update ready")); +} + +#[test] +fn mobile_layout_reserves_only_client_header() { + let config = ClientShellConfig::from_config(&Config::default()); + let state = ClientShellState::new(config); + let layout = state.layout(44, 20); + assert_eq!(layout.mobile_header, Rect::new(0, 0, 44, 2)); + assert_eq!(layout.pane_surface, Rect::new(0, 2, 44, 18)); + assert_eq!( + state.surface_size(44, 20), + ClientSurfaceSize { cols: 44, rows: 18 } + ); +} + +#[test] +fn mobile_shell_controls_remain_clickable_when_pane_mouse_capture_is_disabled() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.mouse_capture = false; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(44, 20).expect("mobile header"); + assert!(!state.hits.mobile_switch.is_empty()); + let switch = state.hits.mobile_switch; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: switch.x, + row: switch.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Navigate); + state.compose(44, 20).expect("mobile switcher"); + assert!(!state.hits.mobile_close.is_empty()); + assert!(!state.hits.mobile_targets.is_empty()); +} + +#[test] +fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projected = snapshot(); + projected.agents.push(ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi".into()), + display_agent: Some("pi".into()), + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Blocked, + state_change_seq: 1, + state_labels: vec![("blocked".into(), "waiting".into())], + tokens: Vec::new(), + focused: true, + }); + projected.workspaces[0].agent_status = AgentStatus::Blocked; + state.set_snapshot(Box::new(projected)); + let mut projected_surface = surface(); + for cell in &mut projected_surface.frame.cells { + cell.symbol = "X".to_owned(); + } + state.set_pane_surface(projected_surface); + + let header = state.compose(44, 20).expect("mobile header"); + let header_text = header + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(header_text.contains("client-shell")); + assert!(header_text.contains("tab 1")); + assert!(header_text.contains("blocked")); + assert!(header_text.contains("switch")); + assert_eq!(state.hits.mobile_switch, Rect::new(34, 0, 10, 2)); + + let click = |rect: Rect| { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + let opened = state.handle_raw_events(vec![click(state.hits.mobile_switch)]); + assert!(opened.repaint); + assert_eq!(state.mode, ClientShellMode::Navigate); + let switcher = state.compose(44, 20).expect("mobile switcher"); + let switcher_text = switcher + .cells + .chunks(switcher.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!( + !switcher_text.contains('X'), + "switcher must clear the pane surface" + ); + for expected in [ + "switch", + "close", + "agents", + "spaces", + "+ new workspace", + "tabs", + "+ new tab", + "menu", + "settings", + "detach", + ] { + assert!(switcher_text.contains(expected), "missing {expected}"); + } + let workspace_hit = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_1").then_some(*rect) + }) + .expect("workspace hit"); + let focused = state.handle_raw_events(vec![click(workspace_hit)]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); + assert!(focused.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(params) + if params.workspace_id == "ws_1" + ) + ))); + + state.compose(44, 20).expect("restored mobile header"); + state.handle_raw_events(vec![click(state.hits.mobile_switch)]); + state.compose(44, 20).expect("agent switcher"); + let agent_hit = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Agent(id) if id == "pane_1").then_some(*rect) + }) + .expect("agent hit"); + let focused = state.handle_raw_events(vec![click(agent_hit)]); + assert!(focused.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_1" + ) + ))); + + state.compose(44, 20).expect("restored mobile header"); + state.handle_raw_events(vec![click(state.hits.mobile_switch)]); + state.compose(44, 20).expect("tab switcher"); + let tab_hit = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Tab(id) if id == "tab_1").then_some(*rect) + }) + .expect("tab hit"); + let focused = state.handle_raw_events(vec![click(tab_hit)]); + assert!(focused.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::TabFocus(params) + if params.tab_id == "tab_1" + ) + ))); +} + +#[test] +fn mobile_background_workspace_uses_its_own_active_tab_status() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projected = snapshot(); + projected.tabs.push(ClientShellTab { + tab_id: "tab_7".into(), + workspace_id: "ws_1".into(), + number: 7, + label: "logs".into(), + custom_label: true, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + projected.workspaces.push(ClientShellWorkspace { + workspace_id: "ws_2".into(), + active_tab_id: "tab_3".into(), + new_workspace_cwd: "/feature".into(), + number: 2, + label: "background".into(), + custom_label: true, + branch: Some("feature".into()), + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: false, + agent_status: AgentStatus::Idle, + }); + for (number, tab_id, label) in [(1, "tab_2", "one"), (7, "tab_3", "two")] { + projected.tabs.push(ClientShellTab { + tab_id: tab_id.into(), + workspace_id: "ws_2".into(), + number, + label: label.into(), + custom_label: true, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.navigate_workspace_id = Some("ws_2".into()); + let frame = state.compose(44, 20).expect("mobile switcher"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("feature · tab two · 2/2"), "{text}"); + assert!(text.contains("2 · logs"), "{text}"); + assert!(!text.contains("7 · logs"), "{text}"); +} + +#[test] +fn mobile_switcher_create_and_menu_rows_reuse_client_actions() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.prompt_new_workspace_name = true; + config.prompt_new_tab_name = true; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let click = |rect: Rect| { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile create switcher"); + let new_tab = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| matches!(target, ClientMobileTarget::NewTab).then_some(*rect)) + .expect("new tab hit"); + state.handle_raw_events(vec![click(new_tab)]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::NewTab { .. }, + .. + })) + )); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile workspace switcher"); + let new_workspace = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::NewWorkspace).then_some(*rect) + }) + .expect("new workspace hit"); + state.handle_raw_events(vec![click(new_workspace)]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::NewWorkspace { .. }, + .. + })) + )); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile menu switcher"); + let settings = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| matches!(target, ClientMobileTarget::Menu(0)).then_some(*rect)) + .expect("settings hit"); + state.handle_raw_events(vec![click(settings)]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); +} + +#[test] +fn mobile_menu_keeps_inert_notes_open_and_cancel_without_workspace_in_navigate() { + let mut source_config = Config::default(); + source_config.ui.prompt_new_workspace_name = true; + let config = ClientShellConfig::from_config(&source_config); + let mut projected = snapshot(); + projected.latest_release_notes_available = true; + projected.release_notes = None; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile switcher"); + let inert_notes = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| matches!(target, ClientMobileTarget::Menu(3)).then_some(*rect)) + .expect("what's new row"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: inert_notes.x, + row: inert_notes.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert!(state.overlay.is_none()); + assert!(!state.mobile_switcher_suspended); + + let mut empty = snapshot(); + empty.focused_workspace_id = None; + empty.focused_tab_id = None; + empty.focused_pane_id = None; + empty.workspaces.clear(); + empty.tabs.clear(); + empty.panes.clear(); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&source_config)); + state.set_snapshot(Box::new(empty)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("empty mobile switcher"); + let new_workspace = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::NewWorkspace).then_some(*rect) + }) + .expect("new workspace row"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: new_workspace.x, + row: new_workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!(state.overlay, Some(ClientShellOverlay::Rename(_)))); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Navigate); +} + +#[test] +fn mobile_previous_workspace_action_wraps_across_expanded_entries() { + let mut projected = snapshot(); + for index in 2..=3 { + projected.workspaces.push(ClientShellWorkspace { + workspace_id: format!("ws_{index}"), + active_tab_id: format!("tab_{index}"), + new_workspace_cwd: "/tmp".into(), + number: index, + label: format!("workspace-{index}"), + custom_label: true, + branch: None, + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(44, 20).expect("mobile layout"); + let mut outcome = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::PreviousWorkspace), + &mut outcome, + ); + assert!(outcome.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_3" + ) + ))); +} + +#[test] +fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projected = snapshot(); + for index in 2..=8 { + projected.workspaces.push(ClientShellWorkspace { + workspace_id: format!("ws_{index}"), + active_tab_id: format!("tab_{index}"), + new_workspace_cwd: "/tmp".into(), + number: index, + label: format!("workspace-{index}"), + custom_label: true, + branch: None, + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(44, 10).expect("mobile header"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: state.hits.mobile_switch.x, + row: state.hits.mobile_switch.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(44, 10).expect("mobile switcher"); + let wheel = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 20, + row: 8, + modifiers: KeyModifiers::empty(), + })]); + assert!(wheel.repaint); + assert_eq!(state.mobile_switcher_scroll, 2); + state.compose(44, 10).expect("wheel position stays stable"); + assert_eq!(state.mobile_switcher_scroll, 2); + for _ in 0..7 { + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Down, + KeyModifiers::empty(), + ))]); + } + state.compose(44, 10).expect("revealed mobile selection"); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8")); + assert!(state.mobile_switcher_scroll > 2); + assert!(state.hits.mobile_targets.iter().any(|(_, target)| { + matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_8") + })); + let close = state.hits.mobile_close; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: close.x, + row: close.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); + + state.compose(80, 20).expect("desktop transition"); + assert!(state.hits.mobile_switch.is_empty()); + assert!(state.hits.mobile_close.is_empty()); + assert!(state.hits.mobile_targets.is_empty()); + + state.mode = ClientShellMode::Navigate; + let short = state.compose(44, 2).expect("short mobile switcher"); + assert_eq!(short.cells[0].symbol, "─"); + assert!(state.hits.mobile_close.is_empty()); + assert!(state.hits.mobile_targets.is_empty()); +} diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs new file mode 100644 index 00000000..4cf97152 --- /dev/null +++ b/src/client/shell/tests/mod.rs @@ -0,0 +1,220 @@ +use super::*; +use crate::api::schema::AgentStatus; +use crate::protocol::{ + ClientShellAgent, ClientShellPane, ClientShellTab, ClientShellWorktree, PaneSurfacePane, + PaneSurfaceSplit, PaneSurfaceSplitDirection, SurfaceRect, +}; +use crossterm::event::MouseEvent; + +fn snapshot() -> ClientShellSnapshot { + ClientShellSnapshot { + boot_id: "boot-1".into(), + revision: 1, + config_diagnostic: None, + product_announcement: None, + update_available: None, + update_install_command: "herdr update".into(), + server_keybindings_toml: None, + latest_release_notes_available: false, + integration_updates_available: false, + worktree_directory: "/tmp/herdr-worktrees".into(), + release_notes: None, + focused_workspace_id: Some("ws_1".into()), + focused_tab_id: Some("tab_1".into()), + focused_pane_id: Some("pane_1".into()), + tab_bar_right: Vec::new(), + tab_bar_right_separator: " ".into(), + agent_view_label: None, + agent_order: Vec::new(), + workspaces: vec![ClientShellWorkspace { + workspace_id: "ws_1".into(), + active_tab_id: "tab_1".into(), + new_workspace_cwd: "/repo".into(), + number: 1, + label: "client-shell".into(), + custom_label: false, + branch: Some("main".into()), + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: true, + agent_status: AgentStatus::Idle, + }], + tabs: vec![ClientShellTab { + tab_id: "tab_1".into(), + workspace_id: "ws_1".into(), + number: 1, + label: "1".into(), + custom_label: false, + zoomed: false, + focused: true, + agent_status: AgentStatus::Idle, + }], + panes: vec![ClientShellPane { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + label: None, + cwd: Some("/repo".into()), + foreground_cwd: Some("/repo".into()), + focused: true, + right_click_passthrough: false, + }], + agents: Vec::new(), + commands: Vec::new(), + } +} + +fn worktree_list_result(open_workspace_id: Option<&str>) -> crate::api::schema::ResponseResult { + crate::api::schema::ResponseResult::WorktreeList { + source: crate::api::schema::WorktreeSourceInfo { + repo_key: "repo-key".into(), + repo_name: "repo".into(), + repo_root: "/repo".into(), + source_checkout_path: "/repo".into(), + source_workspace_id: Some("ws_1".into()), + }, + worktrees: vec![crate::api::schema::WorktreeInfo { + path: "/repo-feature".into(), + branch: Some("feature".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + open_workspace_id: open_workspace_id.map(str::to_owned), + label: "repo".into(), + }], + } +} + +fn surface() -> PaneSurfaceFrame { + let surface_buffer = Buffer::with_lines(["LIVE", "PANE"]); + PaneSurfaceFrame { + boot_id: "boot-1".into(), + projection_revision: 1, + surface_revision: 1, + frame: FrameData::from_ratatui_buffer_with_hyperlinks( + &surface_buffer, + Some(crate::protocol::CursorState { + x: 1, + y: 1, + visible: true, + shape: 2, + }), + &[], + ), + panes: vec![PaneSurfacePane { + pane_id: "pane_1".into(), + content_revision: 0, + rect: SurfaceRect { + x: 0, + y: 0, + width: 4, + height: 2, + }, + inner_rect: SurfaceRect { + x: 0, + y: 0, + width: 4, + height: 2, + }, + scrollbar_rect: None, + scroll: None, + focused: true, + mouse_reporting: false, + sgr_pixel_mouse: false, + alternate_screen_active: false, + pixel_width: 0, + pixel_height: 0, + }], + splits: Vec::new(), + popup: None, + graphics: crate::protocol::SurfaceGraphicsScene::default(), + } +} + +fn pane_scroll_result( + offset_from_bottom: u64, + max_offset_from_bottom: u64, + viewport_rows: u64, +) -> crate::api::schema::ResponseResult { + crate::api::schema::ResponseResult::PaneInfo { + pane: crate::api::schema::PaneInfo { + pane_id: "pane_1".into(), + terminal_id: "terminal_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + focused: true, + cwd: None, + foreground_cwd: None, + label: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + display_agent: None, + agent_status: crate::api::schema::AgentStatus::Unknown, + state_labels: HashMap::new(), + tokens: HashMap::new(), + agent_session: None, + scroll: Some(crate::api::schema::PaneScrollInfo { + offset_from_bottom, + max_offset_from_bottom, + viewport_rows, + }), + revision: 0, + }, + } +} + +fn copy_search_result( + matches: Vec<crate::api::schema::PaneTextRange>, + current: Option<u32>, +) -> crate::api::schema::ResponseResult { + let total = matches.len() as u64; + crate::api::schema::ResponseResult::PaneCopySearch { + pane_id: "pane_1".into(), + content_revision: 0, + matches, + total, + current, + current_global: current.map(u64::from), + } +} + +fn surface_with_popup() -> PaneSurfaceFrame { + let mut surface = surface(); + let popup_buffer = Buffer::with_lines(["popup-live", "", ""]); + surface.popup = Some(Box::new(crate::protocol::ClientShellPopupSurface { + terminal_id: "terminal-popup".into(), + title: "popup title".into(), + width: Some(crate::protocol::ClientShellPopupSize::Cells(12)), + height: Some(crate::protocol::ClientShellPopupSize::Cells(5)), + frame: FrameData::from_ratatui_buffer_with_hyperlinks( + &popup_buffer, + Some(crate::protocol::CursorState { + x: 2, + y: 1, + visible: true, + shape: 1, + }), + &[], + ), + mouse_reporting: true, + sgr_pixel_mouse: false, + pixel_width: 0, + pixel_height: 0, + })); + surface +} + +mod agents_worktrees_notifications; +mod chrome_context; +mod copy; +#[path = "input.rs"] +mod input_domain; +mod keybindings_settings; +mod mobile; +mod mouse_selection; +mod popup_focus_projection; +mod startup_overlays; diff --git a/src/client/shell/tests/mouse_selection.rs b/src/client/shell/tests/mouse_selection.rs new file mode 100644 index 00000000..9734db7c --- /dev/null +++ b/src/client/shell/tests/mouse_selection.rs @@ -0,0 +1,686 @@ +use super::*; + +#[test] +fn ctrl_click_routes_link_activation_through_endpoint_then_client_host() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::CONTROL, + }; + let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + let [ClientShellAction::Endpoint { request, .. }] = &activate.actions[..] else { + panic!("expected link activation request"); + }; + let request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneLinkActivate(params) + if params.pane_id == "pane_1" && params.viewport_row == 1 && params.col == 2 + )); + + let up = MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + ..down + }; + let held = state.handle_raw_events(vec![RawInputEvent::Mouse(up)]); + assert!(held.requests.is_empty() && held.actions.is_empty()); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: Some("https://example.test".to_owned()), + handled: false, + }), + ); + assert!(matches!( + &actions[..], + [ClientShellAction::OpenSafeWebUrl(url)] if url == "https://example.test" + )); + assert!(!state.url_click_consumes_until_up); +} + +#[test] +fn ctrl_click_without_a_link_replays_the_original_gesture() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::CONTROL, + }; + let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + let request_id = match &activate.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + _ => panic!("expected link activation request"), + }; + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: None, + handled: false, + }), + ); + assert!(matches!( + &actions[..], + [ClientShellAction::ReplayMouse(events)] if events == &vec![down] + )); + let replay = match actions.into_iter().next().expect("replay action") { + ClientShellAction::ReplayMouse(events) => state.replay_mouse_events(events), + _ => unreachable!(), + }; + assert!(matches!( + &replay.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::PaneFocus(_)) + )); + assert!(state.selection.is_some()); +} + +#[test] +fn pane_split_drag_uses_projected_handle_and_stable_tab_path() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.splits.push(PaneSurfaceSplit { + direction: PaneSurfaceSplitDirection::Horizontal, + pos: 40, + area: SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 19, + }, + hit_rect: SurfaceRect { + x: 40, + y: 0, + width: 1, + height: 19, + }, + path: vec![false, true], + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("split pane surface"); + let split = state.hits.pane_splits[0].clone(); + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: split.hit_rect.x, + row: split.hit_rect.y + 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::PaneSplit { .. }) + )); + let mut replacement = snapshot(); + replacement.revision = 2; + replacement + .tab_bar_right + .push(crate::protocol::ClientShellTabStatusSegment { + text: "updated".into(), + accent: false, + }); + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + replacement_surface.splits.push(PaneSurfaceSplit { + direction: PaneSurfaceSplitDirection::Horizontal, + pos: 40, + area: SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 19, + }, + hit_rect: SurfaceRect { + x: 40, + y: 0, + width: 1, + height: 19, + }, + path: vec![false, true], + }); + state.set_snapshot(Box::new(replacement)); + state.set_pane_surface(replacement_surface); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: split.area.x + 48, + row: split.hit_rect.y + 2, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &drag.actions[..] else { + panic!("pane split drag should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::LayoutSetSplitRatio(params) + if params.tab_id.as_deref() == Some("tab_1") + && params.path == vec![false, true] + && (params.ratio - 0.6).abs() < f32::EPSILON + )); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: split.area.x + 48, + row: split.hit_rect.y + 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(release.actions.is_empty()); + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn disabled_mouse_chrome_keeps_tab_wheel_but_removes_split_drag_hits() { + let mut config = Config::default(); + config.ui.mouse_capture = false; + let mut projected = snapshot(); + let mut second_tab = projected.tabs[0].clone(); + second_tab.tab_id = "tab_2".into(); + second_tab.number = 2; + second_tab.label = "2".into(); + second_tab.focused = false; + projected.tabs.push(second_tab); + let mut pane_surface = surface(); + pane_surface.splits.push(PaneSurfaceSplit { + direction: PaneSurfaceSplitDirection::Horizontal, + pos: 40, + area: SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 19, + }, + hit_rect: SurfaceRect { + x: 40, + y: 0, + width: 1, + height: 19, + }, + path: Vec::new(), + }); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("mouse-disabled shell"); + assert!(state.hits.pane_splits.is_empty()); + let first_tab = state.hits.tabs[0].0; + let wheel = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: first_tab.x, + row: first_tab.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &wheel.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_2" + ) + )); +} + +#[test] +fn client_double_click_selects_and_copies_endpoint_row_word() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let click = || { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + let release = || { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + + state.handle_raw_events(vec![click()]); + state.handle_raw_events(vec![release()]); + let second = state.handle_raw_events(vec![click()]); + let ClientShellAction::Endpoint { request, .. } = second + .actions + .iter() + .find(|action| { + matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + ) + }) + .expect("word-row read") + else { + unreachable!() + }; + let word_request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 0 } + && params.cursor == crate::api::schema::PaneTextPoint { row: 0, col: 3 } + )); + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &word_request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "LIVE".into(), + }), + ); + assert!(repaint); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_finalized)); + let [ClientShellAction::Endpoint { request, .. }] = &actions[..] else { + panic!("auto-copy should read the selected word"); + }; + let copy_request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor.col == 0 && params.cursor.col == 3 + )); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + ©_request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "LIVE".into(), + }), + ); + assert!(matches!( + &actions[..], + [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"LIVE" + )); +} + +#[test] +fn pane_mouse_input_keeps_stable_target_and_endpoint_encoding() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::ALT, + })]); + let [ClientMessage::ClientShellPaneInput { pane_id, events }] = &click.requests[..] else { + panic!("pane application click should use targeted canonical input"); + }; + assert_eq!(pane_id, "pane_1"); + assert!(matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Cell { column: 2, row: 1 }, + modifiers, + .. + }] if *modifiers == KeyModifiers::ALT.bits() + )); + let moved = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Moved, + column: 0, + row: 0, + modifiers: KeyModifiers::ALT, + })]); + assert!(moved.requests.is_empty()); + assert!(state.pane_mouse_gesture.is_some()); + state.hits.panes.clear(); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::ALT, + })]); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn pane_pixel_mouse_preserves_pane_relative_pixel_coordinates() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + pane_surface.panes[0].sgr_pixel_mouse = true; + pane_surface.panes[0].pixel_width = 39; + pane_surface.panes[0].pixel_height = 38; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let geometry = + crate::input::mouse::HostGeometry::new(106, 20, 1060, 400).expect("host geometry"); + let x = u32::from(pane.inner_rect.x) * 10 + 21; + let y = u32::from(pane.inner_rect.y) * 20 + 21; + let report = format!("\x1b[<0;{x};{y}M"); + + let outcome = state.handle_pixel_mouse(report.as_bytes(), geometry); + assert!(matches!( + &outcome.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Pixels { x: 20, y: 20, .. }, + .. + }] + ) + )); + + let lost = state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(matches!( + &lost.requests[..], + [ + ClientMessage::ClientShellPaneInput { pane_id, events }, + ClientMessage::ClientShellFocus { focused: false } + ] if pane_id == "pane_1" && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Pixels { x: 20, y: 20, .. }, + .. + }] + ) + )); +} + +#[test] +fn pane_owned_right_click_forwards_the_complete_gesture() { + let mut snapshot = snapshot(); + snapshot.panes[0].right_click_passthrough = true; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1" + )); + assert!(state.overlay.is_none()); + assert!(state.pane_mouse_gesture.is_some()); + + let up = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Right), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &up.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Right + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn tab_click_waits_for_release_and_drag_reorders_by_stable_id() { + let mut projected = snapshot(); + for index in 2..=3 { + let mut tab = projected.tabs[0].clone(); + tab.tab_id = format!("tab_{index}"); + tab.number = index; + tab.label = index.to_string(); + tab.focused = false; + projected.tabs.push(tab); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("three tabs"); + let first = state.hits.tabs[0].0; + let third = state.hits.tabs[2].0; + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x + 1, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(down.actions.is_empty()); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.right().saturating_sub(1), + row: third.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(drag.repaint); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::Tab { + ref tab_id, + insert_index: Some(3), + .. + }) if tab_id == "tab_1" + )); + let frame = state.compose(106, 20).expect("tab drop indicator"); + assert!(frame + .cells + .iter() + .take(frame.width as usize) + .any(|cell| cell.symbol == "│")); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: third.right().saturating_sub(1), + row: third.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &release.actions[..] else { + panic!("tab drag should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::TabMove(params) + if params.tab_id == "tab_1" && params.insert_index == 3 + )); + + state.compose(106, 20).expect("tabs after drag"); + let second = state.hits.tabs[1].0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: second.x + 1, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: second.x + 1, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &click.actions[0], + ClientShellAction::Endpoint { request, .. } + if matches!(&request.method, crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_2") + )); +} + +#[test] +fn tab_drag_clears_its_drop_target_after_leaving_the_tab_row() { + let mut projected = snapshot(); + for index in 2..=3 { + let mut tab = projected.tabs[0].clone(); + tab.tab_id = format!("tab_{index}"); + tab.number = index; + tab.label = index.to_string(); + tab.focused = false; + projected.tabs.push(tab); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("three tabs"); + let first = state.hits.tabs[0].0; + let third = state.hits.tabs[2].0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x + 1, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.x, + row: third.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.x, + row: third.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::Tab { + insert_index: None, + .. + }) + )); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: third.x, + row: third.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(release.actions.is_empty()); +} + +#[test] +fn tab_wheel_switches_tabs_without_changing_overflow_scroll() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("tab bar"); + let tab = state.hits.tabs[0].0; + + let outcome = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: tab.x, + row: tab.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &outcome.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_1" + ) + )); + assert_eq!(state.tab_scroll, 0); + state.compose(106, 20).expect("tab bar after wheel"); + assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_1")); +} + +#[test] +fn context_menu_keyboard_and_outside_click_are_client_owned() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let tab = state.hits.tabs[0].0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: tab.x + 1, + row: tab.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(106, 20).expect("tab context menu"); + let moved = state.handle_input_bytes(b"\x1b[B"); + assert!(moved.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + highlighted: 1, + .. + })) + )); + let text = state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "not pane input", + ))]); + assert!(text.requests.is_empty()); + let paste = state.handle_raw_events(vec![RawInputEvent::Paste("not pane input".into())]); + assert!(paste.requests.is_empty()); + let outside = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 105, + row: 19, + modifiers: KeyModifiers::empty(), + })]); + assert!(outside.repaint); + assert!(state.overlay.is_none()); +} diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs new file mode 100644 index 00000000..6ccdcf6e --- /dev/null +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -0,0 +1,1226 @@ +use super::*; + +#[test] +fn clipboard_image_targets_the_focused_pane_or_active_popup() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + + assert_eq!( + state.clipboard_image_target(), + Some(crate::protocol::ClientClipboardImageTarget::Pane( + "pane_1".into() + )) + ); + + state.mode = ClientShellMode::Prefix; + assert_eq!(state.clipboard_image_target(), None); + state.mode = ClientShellMode::Terminal; + state.overlay = Some(ClientShellOverlay::Onboarding); + assert_eq!(state.clipboard_image_target(), None); + state.overlay = None; + + state.set_pane_surface(surface_with_popup()); + assert_eq!( + state.clipboard_image_target(), + Some(crate::protocol::ClientClipboardImageTarget::Popup( + "terminal-popup".into() + )) + ); + state.overlay = Some(ClientShellOverlay::Onboarding); + assert_eq!(state.clipboard_image_target(), None); +} + +#[test] +fn modal_paste_target_requires_a_focused_editable_client_field() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + assert!(!state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: String::new(), + replace_on_type: false, + target: ClientRenameTarget::Pane { + pane_id: "pane_1".into(), + }, + })); + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { + source_workspace_id: "ws_1".into(), + repo_name: "repo".into(), + branch: String::new(), + checkout_path: String::new(), + replace_on_type: false, + error: None, + creating: true, + }, + )); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::WorktreeCreate(create)) = state.overlay.as_mut() { + create.creating = false; + } + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + source_workspace_id: "ws_1".into(), + entries: Vec::new(), + selected: 0, + query: String::new(), + search_focused: false, + error: None, + opening: false, + }, + )); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::WorktreeOpen(open)) = state.overlay.as_mut() { + open.search_focused = true; + } + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + query: String::new(), + search_focused: false, + selected: 0, + scroll: 0, + filter: None, + expanded_workspaces: HashSet::new(), + })); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.search_focused = true; + } + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: false, + scroll: 0, + })); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::Help(help)) = state.overlay.as_mut() { + help.search_focused = true; + } + assert!(state.modal_paste_target_active()); + + state.overlay = None; + state.copy_mode = Some(ClientCopyModeState { + pane_id: "pane_1".into(), + content_revision: 0, + geometry: (80, 24), + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + offset_from_bottom: 0, + max_offset_from_bottom: 0, + entry_offset_from_bottom: 0, + selection: None, + search_prompt: Some(ClientCopySearchPrompt { + direction: crate::api::schema::PaneCopySearchDirection::Forward, + query: String::new(), + }), + search_query: String::new(), + search_direction: None, + search_matches: Vec::new(), + search_total: 0, + search_current: None, + search_current_global: None, + search_generation: 0, + copy_after_search: false, + }); + assert!(state.modal_paste_target_active()); + state.popup_pending = true; + assert!(!state.modal_paste_target_active()); +} + +#[test] +fn non_overlay_ctrl_v_is_forwarded_to_the_focused_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let key = crate::input::TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL); + + let outcome = state.handle_raw_events(vec![RawInputEvent::Key(key)]); + + assert!(matches!( + &outcome.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!(&events[..], [ClientPaneInputEvent::Key { .. }]) + )); +} + +#[test] +fn desktop_composition_keeps_shell_outside_origin_relative_surface() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 20).expect("composed frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("spaces")); + assert!(text.contains("client-shell")); + assert!(text.contains("main")); + assert!(text.contains("LIVE")); + assert!(!text.contains("1 1")); + assert_eq!( + frame.cursor.as_ref().map(|cursor| (cursor.x, cursor.y)), + Some((27, 2)) + ); +} + +#[test] +fn client_composes_popup_terminal_content_inside_client_owned_chrome() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + + let frame = state.compose(106, 20).expect("popup frame"); + let popup = state.hits.popup.as_ref().expect("popup hit geometry"); + assert_eq!(popup.rect.width, 12); + assert_eq!(popup.rect.height, 5); + assert_eq!(popup.inner_rect.width, 9); + assert_eq!(popup.inner_rect.height, 3); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("popup tit")); + assert!(text.contains("popup-liv")); + assert_eq!( + frame.cursor.as_ref().map(|cursor| (cursor.x, cursor.y)), + Some((popup.inner_rect.x + 2, popup.inner_rect.y + 1)) + ); +} + +#[test] +fn popup_owns_keys_text_paste_and_mouse_before_shell_controls() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + state.compose(106, 20).expect("popup frame"); + + for bytes in [b"x".as_slice(), b"\x02".as_slice(), b"\x1b".as_slice()] { + let input = state.handle_input_bytes(bytes); + assert!(matches!( + &input.requests[..], + [ClientMessage::ClientShellPopupInput { terminal_id, .. }] + if terminal_id == "terminal-popup" + )); + assert_eq!(state.mode, ClientShellMode::Terminal); + } + + let text = state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "ime", + ))]); + assert!(matches!( + &text.requests[..], + [ClientMessage::ClientShellPopupInput { events, .. }] + if matches!(&events[..], [ClientPaneInputEvent::TextCommit(value)] if value == "ime") + )); + let paste = state.handle_raw_events(vec![RawInputEvent::Paste("paste".into())]); + assert!(matches!( + &paste.requests[..], + [ClientMessage::ClientShellPopupInput { events, .. }] + if matches!(&events[..], [ClientPaneInputEvent::Paste(value)] if value == "paste") + )); + + let popup = state.hits.popup.as_ref().expect("popup hit").clone(); + let mouse = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: popup.inner_rect.x + 3, + row: popup.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &mouse.requests[..], + [ClientMessage::ClientShellPopupInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + position: ClientMousePosition::Cell { column: 3, row: 1 }, + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_some()); + state.set_pane_surface(surface()); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn popup_transition_dismisses_client_overlays_and_restores_pane_input_after_close() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help), + &mut ClientShellInput::default(), + ); + assert!(state.overlay.is_some()); + + state.set_pane_surface(surface_with_popup()); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + let popup_input = state.handle_input_bytes(b"p"); + assert!(matches!( + &popup_input.requests[..], + [ClientMessage::ClientShellPopupInput { .. }] + )); + + state.set_pane_surface(surface()); + let pane_input = state.handle_input_bytes(b"p"); + assert!(matches!( + &pane_input.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1" + )); +} + +#[test] +fn popup_target_survives_surface_invalidation_during_resize() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + state.invalidate_pane_surface(); + + assert!(matches!( + &state.handle_input_bytes(b"x").requests[..], + [ClientMessage::ClientShellPopupInput { terminal_id, .. }] + if terminal_id == "terminal-popup" + )); + let mouse = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(mouse.requests.is_empty()); + assert!(mouse.actions.is_empty()); +} + +#[test] +fn popup_close_reprocesses_held_key_repeats_into_the_focused_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + + let press = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('x'), + KeyModifiers::empty(), + ))]); + assert!(matches!( + &press.requests[..], + [ClientMessage::ClientShellPopupInput { .. }] + )); + + state.set_pane_surface(surface()); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(matches!( + &repeat.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1" + )); +} + +#[test] +fn pending_popup_suppresses_held_pane_repeats_but_preserves_release() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()); + assert!(matches!( + &state + .handle_raw_events(vec![RawInputEvent::Key(key.clone())]) + .requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + state.popup_pending = true; + let repeat = state.handle_raw_events(vec![RawInputEvent::Key( + key.clone() + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeat.requests.is_empty()); + let release = state.handle_raw_events(vec![RawInputEvent::Key( + key.with_kind(crossterm::event::KeyEventKind::Release), + )]); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Key { + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + ) + )); +} + +#[test] +fn prefix_input_source_changes_are_client_owned_and_focus_safe() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.switch_ascii_input_source_in_prefix = true; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + assert!(state.take_input_source_changes().is_empty()); + + let prefix = crate::input::TerminalKey::new(KeyCode::Char('b'), KeyModifiers::CONTROL); + state.handle_raw_events(vec![RawInputEvent::Key(prefix)]); + assert_eq!(state.take_input_source_changes(), vec![true]); + + state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(state.take_input_source_changes().is_empty()); + let escape = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); + state.handle_raw_events(vec![RawInputEvent::Key(escape.clone())]); + assert!(state.take_input_source_changes().is_empty()); + state.handle_raw_events(vec![RawInputEvent::OuterFocusGained]); + assert_eq!(state.take_input_source_changes(), vec![false]); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('b'), + KeyModifiers::CONTROL, + ))]); + assert_eq!(state.take_input_source_changes(), vec![true]); + state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + state.handle_raw_events(vec![RawInputEvent::OuterFocusGained]); + assert!(state.take_input_source_changes().is_empty()); + state.handle_raw_events(vec![RawInputEvent::Key(escape)]); + assert_eq!(state.take_input_source_changes(), vec![false]); +} + +#[test] +fn focus_loss_releases_held_pane_keys_before_reporting_focus() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + .with_generated_text(Some("x".to_owned())) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x58, + virtual_scan_code: 0x2d, + unicode: 'x' as u16, + control_key_state: 0, + }); + let press = state.handle_raw_events(vec![RawInputEvent::Key(key)]); + assert!(matches!( + &press.requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + let lost = state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(matches!( + &lost.requests[..], + [ + ClientMessage::ClientShellPaneInput { events, .. }, + ClientMessage::ClientShellFocus { focused: false } + ] if matches!( + &events[..], + [ClientPaneInputEvent::Key { + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + ) + )); + assert!(state.input_leases.is_empty()); +} + +#[test] +fn focus_loss_releases_active_pane_mouse_before_reporting_focus() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + assert!(state.pane_mouse_gesture.is_some()); + + let lost = state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(matches!( + &lost.requests[..], + [ + ClientMessage::ClientShellPaneInput { pane_id, events }, + ClientMessage::ClientShellFocus { focused: false } + ] if pane_id == "pane_1" && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Cell { column: 2, row: 1 }, + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn focus_gain_reports_focus_and_honors_redraw_policy() { + let mut config = Config::default(); + config.ui.redraw_on_focus_gained = false; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let gained = state.handle_raw_events(vec![RawInputEvent::OuterFocusGained]); + assert!(!gained.repaint); + assert!(gained.query_host_appearance); + assert!(matches!( + &gained.requests[..], + [ClientMessage::ClientShellFocus { focused: true }] + )); +} + +#[test] +fn pane_mouse_release_survives_popup_open_transition() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + assert!(state.pane_mouse_gesture.is_some()); + + state.set_pane_surface(surface_with_popup()); + let up = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &up.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn popup_command_blocks_underlying_input_until_surface_or_error() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("t"), + label: "prefix+t".into(), + command: "secret-popup-command".into(), + action: crate::config::CustomCommandAction::Popup, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_popup".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::Popup, + description: None, + }); + state.set_snapshot(Box::new(projection)); + state.set_pane_surface(surface()); + + let mut invoke = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut invoke); + assert!(state.popup_pending); + assert!(state + .handle_input_bytes(b"not-for-pane") + .requests + .is_empty()); + assert!(state + .handle_raw_events(vec![RawInputEvent::Paste("secret".into())]) + .requests + .is_empty()); + + let request_id = match &invoke.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected popup command request, got {other:?}"), + }; + let (repaint, _) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("command_failed".into()), + message: "popup failed".into(), + }), + ); + assert!(repaint); + assert!(!state.popup_pending); + assert!(matches!( + &state.handle_input_bytes(b"p").requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("t"), + label: "prefix+t".into(), + command: "secret-popup-command".into(), + action: crate::config::CustomCommandAction::Popup, + description: None, + width: None, + height: None, + }; + let mut invoke = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut invoke); + let request_id = match &invoke.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected popup command request, got {other:?}"), + }; + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + assert!(state.popup_pending); + assert!(state + .handle_input_bytes(b"still-blocked") + .requests + .is_empty()); + let deadline = state.popup_pending_deadline.expect("pending timeout"); + state.tick_popup_pending(deadline); + assert!(!state.popup_pending); +} + +#[test] +fn shell_refuses_mismatched_projection_and_clears_stale_hits_in_either_order() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("initial frame"); + assert!(!state.hits.panes.is_empty()); + + let mut replacement = snapshot(); + replacement.revision = 2; + state.set_snapshot(Box::new(replacement)); + assert!(state.hits.panes.is_empty()); + assert!(state.compose(106, 20).is_none()); + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("initial frame"); + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + state.set_pane_surface(replacement_surface); + assert!(state.hits.panes.is_empty()); + assert!(state.compose(106, 20).is_none()); +} + +#[test] +fn shell_ignores_older_same_boot_snapshot_and_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut current_snapshot = snapshot(); + current_snapshot.revision = 2; + current_snapshot.workspaces[0].label = "current".into(); + state.set_snapshot(Box::new(current_snapshot)); + let mut current_surface = surface(); + current_surface.projection_revision = 2; + current_surface.frame.cells[0].symbol = "N".into(); + state.set_pane_surface(current_surface); + state.compose(106, 20).expect("current shell"); + assert!(!state.hits.panes.is_empty()); + let held_key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()); + assert!(matches!( + &state + .handle_raw_events(vec![RawInputEvent::Key(held_key.clone())]) + .requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + let mut stale_snapshot = snapshot(); + stale_snapshot.workspaces[0].label = "stale".into(); + state.set_snapshot(Box::new(stale_snapshot)); + let mut stale_surface = surface(); + stale_surface.frame.cells[0].symbol = "O".into(); + state.set_pane_surface(stale_surface); + + let installed_snapshot = state.snapshot.as_deref().expect("current snapshot"); + assert_eq!(installed_snapshot.revision, 2); + assert_eq!(installed_snapshot.workspaces[0].label, "current"); + let installed_surface = state.pane_surface.as_ref().expect("current pane surface"); + assert_eq!(installed_surface.projection_revision, 2); + assert_eq!(installed_surface.frame.cells[0].symbol, "N"); + + let mut ahead_surface = surface(); + ahead_surface.projection_revision = 4; + ahead_surface.frame.cells[0].symbol = "A".into(); + state.set_pane_surface(ahead_surface); + let mut delayed_surface = surface(); + delayed_surface.projection_revision = 3; + delayed_surface.frame.cells[0].symbol = "D".into(); + state.set_pane_surface(delayed_surface); + let installed_surface = state.pane_surface.as_ref().expect("newest pane surface"); + assert_eq!(installed_surface.projection_revision, 4); + assert_eq!(installed_surface.frame.cells[0].symbol, "A"); + + let mut replacement_boot = snapshot(); + replacement_boot.boot_id = "boot-2".into(); + state.set_snapshot(Box::new(replacement_boot)); + assert!(state.pane_surface.is_none()); + assert!(state.hits.panes.is_empty()); + let release = state.handle_raw_events(vec![RawInputEvent::Key( + held_key.with_kind(crossterm::event::KeyEventKind::Release), + )]); + assert!( + release.requests.is_empty(), + "boot replacement must discard held input leases" + ); + let mut prior_boot_surface = surface(); + prior_boot_surface.projection_revision = u64::MAX; + state.set_pane_surface(prior_boot_surface); + assert!( + state.pane_surface.is_none(), + "an old endpoint surface must not cross the boot boundary" + ); +} + +#[test] +fn resize_invalidation_drops_stale_hits_but_preserves_gesture_release() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.pane_mouse_gesture.is_some()); + + state.invalidate_pane_surface(); + assert!(state.pane_surface.is_none()); + assert!(state.hits.panes.is_empty()); + let stale_click = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: 27, + row: 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(stale_click.requests.is_empty()); + assert!(stale_click.actions.is_empty()); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn pane_scrollbar_track_and_thumb_use_stable_endpoint_scroll_requests() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scrollbar_rect = Some(SurfaceRect { + x: 3, + y: 0, + width: 1, + height: 2, + }); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let track = pane.scrollbar_rect.expect("scrollbar track"); + let metrics = pane.scroll.expect("scroll metrics"); + + let track_click = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: track.y, + modifiers: KeyModifiers::empty(), + })]); + let expected = crate::ui::scrollbar_offset_from_row(metrics, track, track.y); + assert!(track_click.requests.is_empty()); + assert!(matches!( + &track_click.actions[..], + [ + ClientShellAction::Endpoint { request: focus, .. }, + ClientShellAction::Endpoint { request: scroll, .. } + ] if matches!( + &focus.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + ) && matches!( + &scroll.method, + crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" + && params.offset_from_bottom == expected as u64 + ) + )); + let track_scroll_id = match &track_click.actions[1] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + state.handle_endpoint_result( + "boot-1", + &track_scroll_id, + Ok(pane_scroll_result(expected as u64, 20, 2)), + ); + + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("scrollbar thumb"); + let thumb_down = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &thumb_down.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + ) + )); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::PaneScrollbar { .. }) + )); + + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.y, + modifiers: KeyModifiers::empty(), + })]); + let expected = crate::ui::scrollbar_offset_from_drag_row(metrics, track, track.y, 0); + assert!(matches!( + &drag.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" + && params.offset_from_bottom == expected as u64 + ) + )); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(release.actions.is_empty()); + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn edit_scrollback_binding_targets_the_focused_endpoint_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut input = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::EditScrollback), + &mut input, + ); + + assert!(input.requests.is_empty()); + assert!(matches!( + &input.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneEditScrollback(target) + if target.pane_id == "pane_1" + ) + )); +} + +#[test] +fn sidebar_scrollbars_use_proportional_shared_geometry_and_drag() { + let mut projected = snapshot(); + for index in 2..=10 { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{index}"); + workspace.number = index; + workspace.label = format!("workspace-{index}"); + workspace.focused = false; + projected.workspaces.push(workspace); + } + for index in 1..=10 { + projected.agents.push(crate::protocol::ClientShellAgent { + pane_id: format!("agent-pane-{index}"), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some(format!("agent-{index}")), + display_agent: None, + agent: Some("codex".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: index, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("overflowing sidebars"); + + for agent in [false, true] { + let (track, metrics) = if agent { + ( + state.hits.agent_scrollbar, + state.hits.agent_scroll_metrics.expect("agent metrics"), + ) + } else { + ( + state.hits.workspace_scrollbar, + state + .hits + .workspace_scroll_metrics + .expect("workspace metrics"), + ) + }; + assert!(track.width > 0); + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("scrollbar thumb"); + assert!(thumb.len > 1); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::empty(), + })]); + let dragged = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::empty(), + })]); + assert!(dragged.repaint); + if agent { + assert_eq!(state.agent_scroll, metrics.max_offset_from_bottom); + } else { + assert_eq!(state.workspace_scroll, metrics.max_offset_from_bottom); + } + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::empty(), + })]); + } +} + +#[test] +fn popup_preemption_cancels_settings_theme_preview() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.open_settings_overlay(); + let original_theme = state.config.theme_name.clone(); + let original_palette = state.config.palette.clone(); + + state.handle_input_bytes(b"j"); + assert_ne!(state.config.theme_name, original_theme); + assert_ne!(state.config.palette.accent, original_palette.accent); + + state.set_pane_surface(surface_with_popup()); + + assert!(!matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + assert_eq!(state.config.theme_name, original_theme); + assert_eq!(state.config.palette, original_palette); +} + +#[test] +fn pending_scroll_target_does_not_relabel_an_older_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface.clone()); + let mut outcome = ClientShellInput::default(); + state.push_pane_scroll_offset("pane_1".into(), 10, &mut outcome); + state.set_pane_surface(pane_surface); + assert_eq!( + state + .pane_surface + .as_ref() + .and_then(|surface| surface.panes[0].scroll) + .map(|scroll| scroll.offset_from_bottom), + Some(0) + ); + assert_eq!(state.pane_scroll_targets.get("pane_1"), Some(&10)); +} + +#[test] +fn retained_surface_patch_updates_only_pane_cells_without_recomposing_chrome() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let pane_surface = surface(); + let mut updated_pane = pane_surface.panes[0].clone(); + updated_pane.content_revision = 2; + state.set_pane_surface(pane_surface); + let composed = state.compose(100, 30).expect("initial composed frame"); + let layout = state.layout(100, 30); + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 0, + y: 0, + cells: vec![ + crate::protocol::CellData { + symbol: "N".into(), + fg: 0, + bg: 0, + modifier: 0, + skip: false, + hyperlink: None, + }; + 4 + ], + }], + panes: vec![updated_pane], + cursor: None, + }; + + let ClientPaneSurfacePatchOutcome::Applied(Some(patch)) = state.apply_pane_surface_patch(patch) + else { + panic!("expected fast retained patch"); + }; + let patched = apply_composed_surface_patch(&composed, patch).expect("apply composed patch"); + let pane_index = usize::from(layout.pane_surface.y) * usize::from(patched.width) + + usize::from(layout.pane_surface.x); + assert_eq!(patched.cells[pane_index].symbol, "N"); + assert_eq!( + patched.cells[0], composed.cells[0], + "sidebar chrome changed" + ); + assert_eq!(state.pane_surface.as_ref().unwrap().surface_revision, 2); + + let mut forced_full = surface(); + forced_full.surface_revision = 3; + forced_full.frame.cells[0].symbol = "F".into(); + state.set_pane_surface(forced_full); + assert_eq!(state.pane_surface.as_ref().unwrap().surface_revision, 3); + assert_eq!( + state.pane_surface.as_ref().unwrap().frame.cells[0].symbol, + "F" + ); +} + +#[test] +fn retained_surface_patch_recomposes_client_owned_mode_and_diagnostic_rows() { + for diagnostic in [false, true] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let pane_surface = surface(); + let mut updated_pane = pane_surface.panes[0].clone(); + updated_pane.content_revision = 2; + state.set_pane_surface(pane_surface); + state.compose(100, 30).expect("initial composed frame"); + if diagnostic { + state.config_diagnostic = Some("invalid config".into()); + } else { + state.mode = ClientShellMode::Prefix; + } + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: Vec::new(), + panes: vec![updated_pane], + cursor: None, + }; + + assert!(matches!( + state.apply_pane_surface_patch(patch), + ClientPaneSurfacePatchOutcome::Applied(None) + )); + } +} + +#[test] +fn retained_surface_patch_updates_scrollbar_cells_and_pane_hit_metadata() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &Buffer::with_lines(["LIVE ", "PANE "]), + None, + &[], + ); + pane_surface.panes[0].rect.width = 5; + state.set_pane_surface(pane_surface.clone()); + let composed = state.compose(100, 30).expect("initial composed frame"); + let layout = state.layout(100, 30); + let mut updated_pane = pane_surface.panes[0].clone(); + updated_pane.scrollbar_rect = Some(SurfaceRect { + x: 4, + y: 0, + width: 1, + height: 2, + }); + updated_pane.scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 2, + max_offset_from_bottom: 8, + viewport_rows: 2, + }); + updated_pane.mouse_reporting = true; + updated_pane.sgr_pixel_mouse = true; + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 4, + y: 0, + cells: vec![crate::protocol::CellData { + symbol: "▐".into(), + fg: 0, + bg: 0, + modifier: 0, + skip: false, + hyperlink: None, + }], + }], + panes: vec![updated_pane], + cursor: None, + }; + + let ClientPaneSurfacePatchOutcome::Applied(Some(patch)) = state.apply_pane_surface_patch(patch) + else { + panic!("expected fast retained patch"); + }; + let patched = apply_composed_surface_patch(&composed, patch).expect("apply composed patch"); + let scrollbar_index = usize::from(layout.pane_surface.y) * usize::from(patched.width) + + usize::from(layout.pane_surface.x + 4); + assert_eq!(patched.cells[scrollbar_index].symbol, "▐"); + let hit = state + .hits + .panes + .iter() + .find(|hit| hit.pane_id == "pane_1") + .expect("pane hit"); + assert_eq!( + hit.scrollbar_rect, + Some(Rect::new( + layout.pane_surface.x + 4, + layout.pane_surface.y, + 1, + 2, + )) + ); + assert_eq!( + hit.scroll.map(|scroll| scroll.max_offset_from_bottom), + Some(8) + ); + assert!(hit.mouse_reporting); + assert!(hit.sgr_pixel_mouse); +} + +#[test] +fn retained_surface_patch_rejects_stale_base_without_mutating_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let before = state.pane_surface.clone(); + let outcome = state.apply_pane_surface_patch(crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 0, + surface_revision: 2, + rows: Vec::new(), + panes: Vec::new(), + cursor: None, + }); + assert!(matches!(outcome, ClientPaneSurfacePatchOutcome::Rejected)); + assert_eq!(state.pane_surface, before); +} diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs new file mode 100644 index 00000000..40ba0bd6 --- /dev/null +++ b/src/client/shell/tests/startup_overlays.rs @@ -0,0 +1,1259 @@ +use super::*; + +#[test] +fn endpoint_product_announcement_is_client_rendered_modal_and_dismissed_by_identity() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.product_announcement = + Some(crate::protocol::ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "A client-owned announcement".into(), + body: (0..40) + .map(|index| format!("- announcement line {index}")) + .collect::<Vec<_>>() + .join("\n"), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + state.set_pane_surface(surface_with_popup()); + + let frame = state.compose(106, 30).expect("announcement frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("A client-owned announcement")); + assert!(text.contains("product announcement · v0.8.2")); + assert!(!state.hits.product_announcement_scrollbar.is_empty()); + + let popup_key = state.handle_input_bytes(b"x"); + assert!(popup_key.requests.is_empty()); + let popup_paste = state.handle_raw_events(vec![RawInputEvent::Paste("secret".into())]); + assert!(popup_paste.requests.is_empty()); + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + state.handle_input_bytes(b"\x1b[6~"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement( + crate::app::state::ProductAnnouncementState { scroll: 11, .. } + )) + )); + let repeated = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::PageDown, KeyModifiers::empty()) + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeated.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement( + crate::app::state::ProductAnnouncementState { scroll: 11, .. } + )) + )); + + let dismissed = state.handle_input_bytes(b"\r"); + assert!(state.overlay.is_none()); + assert!(matches!( + &dismissed.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::ProductAnnouncementDismiss(params) + if params.version == "0.8.2" && params.id == "client-shell" + ) + )); + + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + assert!(state.overlay.is_none(), "same announcement stays dismissed"); + endpoint_snapshot + .product_announcement + .as_mut() + .expect("announcement") + .id = "new-announcement".into(); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + )); + state.chrome_drag = Some(ClientChromeDrag::ProductAnnouncementScrollbar { grab_row_offset: 0 }); + endpoint_snapshot.product_announcement = None; + state.set_snapshot(Box::new(endpoint_snapshot)); + assert!(state.overlay.is_none()); + assert!(state.chrome_drag.is_none()); + assert!(state.dismissed_product_announcement.is_none()); +} + +#[test] +fn failed_product_announcement_dismiss_reopens_authoritative_snapshot() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.product_announcement = + Some(crate::protocol::ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Client shell".into(), + body: "announcement".into(), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + let dismissed = state.handle_input_bytes(b"\r"); + let request_id = match &dismissed.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + actions => panic!("unexpected actions: {actions:?}"), + }; + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("stale_announcement".into()), + message: "dismiss failed".into(), + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + )); + assert!(state.dismissed_product_announcement.is_none()); +} + +#[test] +fn release_notes_reconcile_and_failed_dismiss_reopens_authoritative_snapshot() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: "first notes".into(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + state.open_release_notes(); + + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.4".into(), + body: "second notes".into(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { ref version, .. } + )) if version == "0.8.4" + )); + + let dismissed = state.handle_input_bytes(b"\r"); + let request_id = match &dismissed.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + actions => panic!("unexpected actions: {actions:?}"), + }; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.5".into(), + body: "current notes".into(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("stale_release_notes".into()), + message: "dismiss failed".into(), + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { ref version, .. } + )) if version == "0.8.5" + )); +} + +#[test] +fn product_announcement_mouse_is_modal_and_closes_only_from_its_button() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.product_announcement = + Some(crate::protocol::ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Client shell".into(), + body: (0..40) + .map(|index| format!("- line {index}")) + .collect::<Vec<_>>() + .join("\n"), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface_with_popup()); + state.compose(106, 30).expect("announcement frame"); + + let outside = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + assert!(outside.requests.is_empty()); + assert!(outside.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + )); + + let metrics = state + .hits + .product_announcement_scroll_metrics + .expect("scroll metrics"); + let track = state.hits.product_announcement_scrollbar; + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("thumb"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::NONE, + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + })]); + assert!(state.chrome_drag.is_none()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement( + crate::app::state::ProductAnnouncementState { scroll, .. } + )) if usize::from(scroll) == state.hits.product_announcement_max_scroll + )); + + let close = state.hits.overlay_primary; + let closed = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: close.x, + row: close.y, + modifiers: KeyModifiers::NONE, + })]); + assert!(state.overlay.is_none()); + assert!(matches!( + &closed.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::ProductAnnouncementDismiss(_)) + )); +} + +#[test] +fn onboarding_has_priority_over_endpoint_product_announcement() { + let config = ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); + let mut state = ClientShellState::new(config); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.product_announcement = + Some(crate::protocol::ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Hidden until a later launch".into(), + body: "announcement body".into(), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); +} + +#[test] +fn startup_onboarding_is_client_rendered_and_modal() { + let config = ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); + let mut state = ClientShellState::new(config); + let early = state.handle_input_bytes(b"\r"); + assert!(early.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 20).expect("onboarding frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("terminal workspace manager for coding agents")); + assert!(text.contains("this is a mouse-first terminal")); + assert!(text.contains("ctrl+b enters prefix mode")); + assert!(text.contains("install optional agent integrations")); + assert_eq!(state.hits.overlay_primary.width, 12); + + let ignored = state.handle_input_bytes(b"x"); + assert!(ignored.requests.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); + let outside = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + assert!(outside.actions.is_empty()); + assert!(outside.requests.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); + + state.set_pane_surface(surface_with_popup()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); + let popup_input = state.handle_input_bytes(b"hidden-popup-input"); + assert!(popup_input.requests.is_empty()); + let popup_paste = state.handle_raw_events(vec![RawInputEvent::Paste("secret".into())]); + assert!(popup_paste.requests.is_empty()); +} + +#[test] +fn onboarding_completion_persists_and_opens_endpoint_integrations() { + let path = std::env::temp_dir().join(format!( + "herdr-client-onboarding-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::write(&path, "[terminal]\ndefault_shell = \"fish\"\n") + .expect("write onboarding config"); + let onboarding_config = || { + let mut config = + ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); + config.local_config_path = path.clone(); + config + }; + + let config = onboarding_config(); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let outcome = state.handle_input_bytes(b"\r"); + + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + loading_integrations: true, + .. + })) + )); + assert!(matches!( + &outcome.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::IntegrationList(_)) + )); + let persisted = std::fs::read_to_string(&path).expect("read onboarding config"); + assert!(persisted.contains("onboarding = false")); + assert!(persisted.contains("default_shell = \"fish\"")); + + for input in [b"\x1b[C".as_slice(), b"l".as_slice()] { + let config = onboarding_config(); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let outcome = state.handle_input_bytes(input); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + .. + })) + )); + assert_eq!(outcome.actions.len(), 1); + } + + let config = onboarding_config(); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("onboarding mouse frame"); + let button = state.hits.overlay_primary; + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: button.x, + row: button.y, + modifiers: KeyModifiers::NONE, + })]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + .. + })) + )); + assert_eq!(click.actions.len(), 1); + + let unreadable_path = path.with_extension("dir"); + std::fs::create_dir(&unreadable_path).expect("create unreadable config path"); + let mut config = onboarding_config(); + config.local_config_path = unreadable_path.clone(); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let failed_write = state.handle_input_bytes(b"\r"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + .. + })) + )); + assert_eq!(failed_write.actions.len(), 1); + assert!(state + .config_diagnostic + .as_deref() + .is_some_and(|diagnostic| diagnostic.contains("failed to read config"))); + assert!(unreadable_path.is_dir()); + std::fs::remove_dir(&unreadable_path).expect("remove unreadable config path"); + + std::fs::remove_file(path).expect("remove onboarding config"); +} + +#[test] +fn startup_config_diagnostics_are_client_rendered_and_persist_until_replaced() { + let config = ClientShellConfig::from_config(&Config::default()) + .with_startup_config_diagnostic(Some("local config warning".into())); + let mut state = ClientShellState::new(config); + let mut shared_snapshot = snapshot(); + shared_snapshot.config_diagnostic = Some("local config warning".into()); + state.set_snapshot(Box::new(shared_snapshot)); + assert_eq!( + state.config_diagnostic.as_deref(), + Some("client + endpoint: local config warning") + ); + + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.config_diagnostic = Some("endpoint config warning".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 20).expect("diagnostic frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("client: local config warning")); + assert!(text.contains("endpoint: endpoint config warning")); + + state.handle_input_bytes(b"x"); + assert!(state.config_diagnostic.is_some()); + + state.set_snapshot(Box::new(snapshot())); + assert_eq!( + state.config_diagnostic.as_deref(), + Some("local config warning") + ); +} + +#[test] +fn config_diagnostic_offsets_only_the_pane_rows_it_overlaps() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.toast_delay_seconds = 0; + let mut state = ClientShellState::new(config); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.config_diagnostic = Some("one-line warning".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.visible_notification = Some(ClientVisibleNotification { + event: SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "notification".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: Some(crate::config::ToastHerdrPosition::TopRight), + }, + deadline: std::time::Instant::now(), + }); + + state.compose(106, 20).expect("one-line frame"); + let pane_area = state.layout(106, 20).pane_surface; + assert_eq!(state.hits.notification_toast.y, pane_area.y); + let targetless_hit = state.hits.notification_toast; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: targetless_hit.x, + row: targetless_hit.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.visible_notification.is_some()); + + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.config_diagnostic = Some("first warning\nsecond warning".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.compose(106, 20).expect("two-line frame"); + assert_eq!(state.hits.notification_toast.y, pane_area.y); + + state + .visible_notification + .as_mut() + .expect("visible notification") + .event + .position = Some(crate::config::ToastHerdrPosition::BottomRight); + state.compose(106, 20).expect("bottom notification frame"); + assert_eq!(state.hits.notification_toast.bottom(), 19); +} + +#[test] +fn endpoint_reload_result_does_not_override_snapshot_diagnostic_authority() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.config_diagnostic = Some("endpoint warning".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.pending_requests.insert( + "reload-1".into(), + PendingEndpointRequest { + boot_id: "boot-1".into(), + confirmation_workspace_id: None, + kind: PendingEndpointKind::ReloadConfig, + }, + ); + + state.handle_endpoint_result( + "boot-1", + "reload-1", + Ok(crate::api::schema::ResponseResult::ConfigReload { + status: crate::config::ConfigReloadStatus::Partial, + diagnostics: vec!["keybinding warning".into()], + }), + ); + assert_eq!(state.config_diagnostic.as_deref(), Some("endpoint warning")); + + state.set_snapshot(Box::new(snapshot())); + assert!(state.config_diagnostic.is_none()); +} + +#[test] +fn endpoint_keybindings_hide_only_local_keybinding_diagnostics() { + let config = ClientShellConfig::from_config(&Config::default()) + .with_keybinding_source(ClientShellKeybindingSource::Endpoint); + let diagnostics = vec![ + "unsafe direct keybinding: keys.close_pane would intercept typing".into(), + "theme warning".into(), + ]; + + assert!(config.local_config_diagnostic(&diagnostics[..1]).is_none()); + assert!(config.local_config_diagnostic(&diagnostics).is_some()); +} + +#[test] +fn live_client_config_keeps_sound_diagnostics() { + let mut shell_config = ClientShellConfig::from_config(&Config::default()); + let mut config = Config::default(); + config.ui.sound.path = Some(std::path::PathBuf::from("invalid.wav")); + + let diagnostics = shell_config.apply_live_config(&config, &[], &[]); + assert!(diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("expected an mp3 file"))); +} + +#[test] +fn update_ready_menu_opens_client_owned_release_notes_and_dismisses_by_version() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: (0..40) + .map(|index| format!("- release line {index}")) + .collect::<Vec<_>>() + .join("\n"), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + state.set_pane_surface(surface()); + let shell = state.compose(106, 30).expect("shell frame"); + assert_eq!(state.hits.global_launcher.width, 8); + let launcher = state.hits.global_launcher; + let shell_buffer = shell.to_ratatui_buffer().expect("shell buffer"); + let badge_x = launcher.right().saturating_sub(6); + assert_eq!( + shell_buffer[(badge_x, launcher.y)].fg, + state.config.palette.accent + ); + assert_eq!( + shell_buffer[(badge_x + 2, launcher.y)].fg, + state.config.palette.overlay0 + ); + + state.sidebar_collapsed = true; + let collapsed = state.compose(106, 30).expect("collapsed update shell"); + let collapsed_buffer = collapsed.to_ratatui_buffer().expect("collapsed buffer"); + assert_eq!( + collapsed_buffer[(state.hits.sidebar_toggle.x, state.hits.sidebar_toggle.y)].fg, + state.config.palette.accent + ); + state.sidebar_collapsed = false; + state.mode = ClientShellMode::Navigate; + let navigate = state.compose(106, 30).expect("navigate update status"); + let navigate_text = navigate + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(navigate_text.contains("update ready")); + state.mode = ClientShellMode::Prefix; + let prefix = state + .compose(106, 30) + .expect("prefix without update status"); + let prefix_text = prefix + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(!prefix_text.contains("update ready")); + state.mode = ClientShellMode::Navigate; + + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("update menu"); + let text = menu + .cells + .chunks(menu.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("● update ready")); + let update_row = state.hits.global_menu_rows[3].0; + assert_eq!(update_row.width, 16); + let menu_buffer = menu.to_ratatui_buffer().expect("menu buffer"); + assert_eq!( + menu_buffer[(update_row.x + 1, update_row.y)].fg, + state.config.palette.accent + ); + assert_eq!( + menu_buffer[(update_row.x + 3, update_row.y)].fg, + state.config.palette.text + ); + state.activate_global_menu_item(3, &mut ClientShellInput::default()); + let notes = state.compose(106, 30).expect("release notes"); + let bottom_row_start = usize::from(notes.width) * usize::from(notes.height - 1); + let bottom_row = notes.cells[bottom_row_start..] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(!bottom_row.contains("NAVIGATE")); + let text = notes + .cells + .chunks(notes.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("v0.8.3")); + assert!(text.contains("update ready")); + assert!(text.contains("detach, run herdr update")); + assert!(!state.hits.release_notes_scrollbar.is_empty()); + let outer = crate::ui::centered_popup_rect( + Rect::new(0, 0, 106, 30), + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + ) + .expect("release notes outer"); + let inner = Rect::new(outer.x + 1, outer.y + 1, outer.width - 2, outer.height - 2); + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + assert_eq!( + state.hits.overlay_primary, + crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )) + ); + let notes_buffer = notes.to_ratatui_buffer().expect("release notes buffer"); + let title_cell = ¬es_buffer[(stack.header.x + 1, stack.header.y)]; + assert_eq!(title_cell.fg, state.config.palette.text); + assert!(title_cell.modifier.contains(Modifier::BOLD)); + assert_eq!( + notes_buffer[(state.hits.overlay_primary.x, state.hits.overlay_primary.y)].bg, + state.config.palette.accent + ); + + let outside = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + assert!(outside.requests.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes(_)) + )); + let pane_text = state.handle_raw_events(vec![ + RawInputEvent::Text(crate::input::TextCommit::new("ime")), + RawInputEvent::Paste("secret".into()), + ]); + assert!(pane_text.requests.is_empty()); + + let metrics = state + .hits + .release_notes_scroll_metrics + .expect("release notes scroll metrics"); + let track = state.hits.release_notes_scrollbar; + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("release notes thumb"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::NONE, + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + })]); + assert!(state.chrome_drag.is_none()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll, .. } + )) if usize::from(scroll) == state.hits.release_notes_max_scroll + )); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = state.overlay.as_mut() { + notes.scroll = 0; + } + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll: 3, .. } + )) + )); + let repeated = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::PageDown, KeyModifiers::empty()) + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeated.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll: 3, .. } + )) + )); + let dismissed = state.handle_input_bytes(b"\r"); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(matches!( + &dismissed.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::ReleaseNotesDismiss(params) + if params.version == "0.8.3" + ) + )); + + endpoint_snapshot.boot_id = "boot-2".into(); + endpoint_snapshot.revision = 2; + endpoint_snapshot.update_available = None; + endpoint_snapshot + .release_notes + .as_mut() + .expect("release notes") + .preview = false; + state.set_snapshot(Box::new(endpoint_snapshot)); + let mut installed_surface = surface(); + installed_surface.boot_id = "boot-2".into(); + installed_surface.projection_revision = 2; + state.set_pane_surface(installed_surface); + state.compose(106, 30).expect("installed shell"); + assert_eq!(state.hits.global_launcher.width, 6); + state.toggle_global_menu(); + let installed = state.compose(106, 30).expect("installed menu"); + let installed_text = installed + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(installed_text.contains("what's new")); + assert!(!installed_text.contains("● what's new")); +} + +#[test] +fn coalesced_release_notes_open_and_scroll_uses_current_geometry() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: (0..40) + .map(|index| format!("- release line {index}")) + .collect::<Vec<_>>() + .join("\n"), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("initial shell"); + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 3, + })); + + state.handle_raw_events(vec![ + RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Enter, + KeyModifiers::empty(), + )), + RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::PageDown, + KeyModifiers::empty(), + )), + ]); + + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll: 8, .. } + )) + )); +} + +#[test] +fn coalesced_release_notes_open_and_mouse_uses_current_geometry() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let body = (0..40) + .map(|index| format!("- release line {index}")) + .collect::<Vec<_>>() + .join("\n"); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: body.clone(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("initial shell"); + + let outer = crate::ui::centered_popup_rect( + Rect::new(0, 0, 106, 30), + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + ) + .expect("release notes outer"); + let inner = Rect::new(outer.x + 1, outer.y + 1, outer.width - 2, outer.height - 2); + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let notes = crate::app::state::ReleaseNotesState { + version: "0.8.3".into(), + body, + scroll: 0, + preview: true, + }; + let metrics = crate::ui::release_notes_scroll_metrics( + ¬es, + "herdr update", + stack.content, + &state.config.palette, + ); + let track = crate::ui::release_notes_scrollbar_rect(stack.content, metrics) + .expect("release notes track"); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 3, + })); + state.handle_raw_events(vec![ + RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Enter, + KeyModifiers::empty(), + )), + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + }), + ]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll, .. } + )) if scroll > 0 + )); + + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 3, + })); + let closed = state.handle_raw_events(vec![ + RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Enter, + KeyModifiers::empty(), + )), + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: close.x, + row: close.y, + modifiers: KeyModifiers::NONE, + }), + ]); + assert!(state.overlay.is_none()); + assert!(matches!( + &closed.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::ReleaseNotesDismiss(_)) + )); +} + +#[test] +fn outdated_integration_badges_launcher_settings_and_settings_tab() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.integration_updates_available = true; + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + let shell = state.compose(106, 30).expect("integration attention shell"); + assert_eq!(state.hits.global_launcher.width, 8); + let shell_text = shell + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(shell_text.contains("● menu")); + + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("integration attention menu"); + let menu_text = menu + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(menu_text.contains("● settings")); + assert!(!menu_text.contains("update ready")); + + state.activate_global_menu_item(0, &mut ClientShellInput::default()); + let settings = state.compose(106, 30).expect("settings integration badge"); + let settings_text = settings + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(settings_text.contains("● integrations")); + let integrations_tab = state + .hits + .settings_tabs + .iter() + .find(|(_, section)| *section == ClientSettingsSection::Integrations) + .map(|(rect, _)| *rect) + .expect("integrations tab"); + let settings_buffer = settings.to_ratatui_buffer().expect("settings buffer"); + assert_eq!( + settings_buffer[(integrations_tab.x + 1, integrations_tab.y)].fg, + state.config.palette.accent + ); + assert_eq!( + settings_buffer[(integrations_tab.x + 3, integrations_tab.y)].fg, + state.config.palette.overlay1 + ); +} + +#[test] +fn combined_update_and_integration_attention_preserves_both_badges() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.integration_updates_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: "### Changed\n- Both attention states".into(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("combined attention shell"); + assert_eq!(state.hits.global_launcher.width, 8); + + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("combined attention menu"); + let text = menu + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + let settings = text.find("● settings").expect("settings badge"); + let update = text.find("● update ready").expect("update badge"); + assert!(settings < update); + + state.overlay = None; + state.mode = ClientShellMode::Navigate; + let navigate = state.compose(106, 30).expect("combined attention navigate"); + let navigate_text = navigate + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(navigate_text.contains("update ready")); +} + +#[test] +fn current_release_notes_use_whats_new_without_attention_badge() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.2".into(), + body: "### Changed\n- Client shell".into(), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("shell frame"); + assert_eq!(state.hits.global_launcher.width, 6); + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("what's new menu"); + let text = menu + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(text.contains("what's new")); +} + +#[test] +fn client_settings_preview_restore_and_endpoint_integrations_are_owned_by_overlay() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 0, + })); + let open = state.handle_input_bytes(b"\r"); + assert!(open.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + .. + })) + )); + let original_theme = state.config.theme_name.clone(); + let original_palette = state.config.palette.clone(); + state.handle_input_bytes(b"j"); + assert_ne!(state.config.theme_name, original_theme); + assert_ne!(state.config.palette.accent, original_palette.accent); + state.handle_input_bytes(b"\x1b"); + assert!(state.overlay.is_none()); + assert_eq!(state.config.theme_name, original_theme); + assert_eq!(state.config.palette.accent, original_palette.accent); + + state.open_settings_overlay(); + state.handle_input_bytes(b"j"); + state.handle_input_bytes(b"\t"); + state + .compose(106, 30) + .expect("settings outside-click geometry"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.overlay.is_none()); + assert_eq!(state.config.theme_name, original_theme); + assert_eq!(state.config.palette.accent, original_palette.accent); + + state.open_settings_overlay(); + state.compose(106, 30).expect("settings overlay"); + for _ in 0..3 { + let next = state.handle_input_bytes(b"\t"); + assert!(next.actions.is_empty()); + } + let integrations = state.handle_input_bytes(b"\t"); + let [ClientShellAction::Endpoint { request, .. }] = &integrations.actions[..] else { + panic!("integration section should request endpoint status"); + }; + assert!(matches!( + request.method, + crate::api::schema::Method::IntegrationList(_) + )); + let request_id = request.id.clone(); + assert!( + state + .handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::IntegrationList { + integrations: vec![ + crate::api::schema::IntegrationInfo { + target: crate::api::schema::IntegrationTarget::Codex, + label: "codex".into(), + command: "codex".into(), + available: true, + state: crate::api::schema::IntegrationState::Outdated, + }, + crate::api::schema::IntegrationInfo { + target: crate::api::schema::IntegrationTarget::Claude, + label: "claude".into(), + command: "claude".into(), + available: false, + state: crate::api::schema::IntegrationState::NotInstalled, + }, + ], + }), + ) + .0 + ); + let frame = state.compose(106, 30).expect("loaded integrations"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("update available")); + assert!(text.contains("not found")); + assert!(!text.contains("pane labels")); + + let popup = state.hits.settings_popup; + let blank_click = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: popup.right().saturating_sub(2), + row: popup.y + 3, + modifiers: KeyModifiers::empty(), + })]); + assert!(!blank_click.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + + let install = state.handle_input_bytes(b"\r"); + assert_eq!(install.actions.len(), 1); + assert!(matches!( + &install.actions[0], + ClientShellAction::Endpoint { request, .. } + if matches!( + request.method, + crate::api::schema::Method::IntegrationInstall( + crate::api::schema::IntegrationInstallParams { + target: crate::api::schema::IntegrationTarget::Codex + } + ) + ) + )); + let escape = state.handle_input_bytes(b"\x1b"); + assert!(!escape.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + let install_request_id = match &install.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!("integration install action"), + }; + let (repaint, refresh_actions) = state.handle_endpoint_result( + "boot-1", + &install_request_id, + Ok(crate::api::schema::ResponseResult::IntegrationInstall { + target: crate::api::schema::IntegrationTarget::Codex, + details: crate::api::schema::IntegrationInstallResult { + messages: vec!["installed codex".into()], + }, + }), + ); + assert!(repaint); + assert!(matches!( + refresh_actions.as_slice(), + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::IntegrationList(_)) + )); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + loading_integrations: true, + installing_integrations: false, + ref integration_messages, + .. + })) if integration_messages == &["installed codex"] + )); +} diff --git a/src/client/shell/worktree_overlays.rs b/src/client/shell/worktree_overlays.rs new file mode 100644 index 00000000..a2b5c65f --- /dev/null +++ b/src/client/shell/worktree_overlays.rs @@ -0,0 +1,410 @@ +use super::*; + +pub(super) fn render_worktree_create_overlay( + b: &mut Buffer, + create: &ClientWorktreeCreateOverlay, + p: &Palette, +) -> Option<OverlayRender> { + let popup = popup(b.area, 68, 12)?; + let inner = panel(b, popup, p.accent, p.panel_bg)?; + put_text( + b, + inner.x, + inner.y, + inner.width, + "new worktree", + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + b, + inner.x, + inner.y + 2, + inner.width, + " branch", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + let input = Rect::new(inner.x, inner.y + 3, inner.width, 1); + b.set_style(input, Style::default().fg(p.text).bg(p.surface0)); + put_text( + b, + input.x, + input.y, + input.width, + &format!(" {}", create.branch), + Style::default().fg(p.text).bg(p.surface0), + ); + put_text( + b, + inner.x, + inner.y + 5, + inner.width, + " checkout", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 6, + inner.width, + &format!(" {}", create.checkout_path), + Style::default().fg(p.subtext0).bg(p.panel_bg), + ); + if create.creating { + put_text( + b, + inner.x, + inner.y + 8, + inner.width, + " creating…", + Style::default().fg(p.accent).bg(p.panel_bg), + ); + } else if let Some(error) = create.error.as_deref() { + put_text( + b, + inner.x, + inner.y + 8, + inner.width, + &format!(" {error}"), + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + let buttons = row(inner, &[20, 12], 2, 9); + let [primary, cancel] = buttons.as_slice() else { + return None; + }; + button( + b, + *primary, + " ↵ create and open ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *primary, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: (!create.creating).then(|| crate::protocol::CursorState { + x: (input.x + 1 + display_width(&create.branch)).min(input.right() - 1), + y: input.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +pub(super) fn render_worktree_open_overlay( + b: &mut Buffer, + open: &ClientWorktreeOpenOverlay, + p: &Palette, +) -> Option<OverlayRender> { + let popup_height = (open.entries.len().saturating_mul(2) + 7).clamp(12, 26) as u16; + let popup = popup(b.area, 96, popup_height)?; + let inner = panel(b, popup, p.accent, p.panel_bg)?; + put_text( + b, + inner.x, + inner.y, + inner.width, + "open worktree", + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + let search = Rect::new(inner.x, inner.y + 1, inner.width, 1); + let filtered = open.filtered_indices(); + put_text( + b, + search.x, + search.y, + search.width, + &if open.search_focused || !open.query.is_empty() { + format!(" / {}", open.query) + } else { + " / filter worktrees".to_owned() + }, + Style::default() + .fg(if open.search_focused { + p.text + } else { + p.overlay0 + }) + .bg(p.panel_bg), + ); + let count = if filtered.len() == open.entries.len() { + format!("{} checkouts", open.entries.len()) + } else { + format!("{}/{} checkouts", filtered.len(), open.entries.len()) + }; + put_right_text( + b, + search, + search.y, + &count, + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 2, + inner.width, + &"─".repeat(inner.width as usize), + Style::default().fg(p.surface1).bg(p.panel_bg), + ); + let body = Rect::new( + inner.x, + inner.y + 3, + inner.width, + inner.height.saturating_sub(6), + ); + let visible_count = (body.height / 2).max(1) as usize; + let selected_position = filtered + .iter() + .position(|index| *index == open.selected) + .unwrap_or(0); + let start = selected_position + .saturating_sub(visible_count.saturating_sub(1)) + .min(filtered.len().saturating_sub(visible_count)); + let mut row_hits = Vec::new(); + for (visible, entry_index) in filtered + .iter() + .copied() + .skip(start) + .take(visible_count) + .enumerate() + { + let entry = &open.entries[entry_index]; + let rect = Rect::new(body.x, body.y + visible as u16 * 2, body.width, 2); + row_hits.push((rect, entry_index)); + let selected = entry_index == open.selected; + let style = if selected { + Style::default().fg(contrast(p)).bg(p.accent) + } else { + Style::default().fg(p.text).bg(p.panel_bg) + }; + b.set_style(rect, style); + put_text( + b, + rect.x, + rect.y, + rect.width, + &format!(" {}", entry.label), + style.add_modifier(Modifier::BOLD), + ); + let status = entry.status_label(); + if !status.is_empty() { + put_right_text(b, rect, rect.y, status, style); + } + put_text( + b, + rect.x, + rect.y + 1, + rect.width, + &format!(" {}", entry.path), + if selected { + style + } else { + Style::default().fg(p.overlay0).bg(p.panel_bg) + }, + ); + } + if filtered.is_empty() { + put_text( + b, + body.x, + body.y, + body.width, + " no matching worktrees", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + } + if open.opening { + put_text( + b, + inner.x, + inner.bottom() - 3, + inner.width, + " opening…", + Style::default().fg(p.accent).bg(p.panel_bg), + ); + } else if let Some(error) = open.error.as_deref() { + put_text( + b, + inner.x, + inner.bottom() - 3, + inner.width, + &format!(" {error}"), + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + let buttons = row(inner, &[10, 12], 2, inner.height.saturating_sub(1)); + let [primary, cancel] = buttons.as_slice() else { + return None; + }; + button( + b, + *primary, + " ↵ open ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *primary, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: search, + worktree_rows: row_hits, + cursor: (open.search_focused && !open.opening).then(|| crate::protocol::CursorState { + x: (search.x + 3 + display_width(&open.query)).min(search.right() - 1), + y: search.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +pub(super) fn render_worktree_remove_overlay( + b: &mut Buffer, + remove: &ClientWorktreeRemoveOverlay, + p: &Palette, +) -> Option<OverlayRender> { + let popup = popup(b.area, 72, 10)?; + let inner = panel(b, popup, p.red, p.panel_bg)?; + put_text( + b, + inner.x, + inner.y, + inner.width, + " delete worktree checkout?", + Style::default() + .fg(p.red) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + b, + inner.x, + inner.y + 1, + inner.width, + " This removes the checkout folder:", + Style::default().fg(p.text).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 2, + inner.width, + &format!(" {}", remove.path), + Style::default().fg(p.subtext0).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 3, + inner.width, + " The branch is not deleted. The Herdr workspace will close.", + Style::default().fg(p.text).bg(p.panel_bg), + ); + if remove.force_confirmation { + put_text( + b, + inner.x, + inner.y + 4, + inner.width, + " Dirty or untracked files will be permanently deleted.", + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + if remove.removing { + put_text( + b, + inner.x, + inner.y + 5, + inner.width, + " removing…", + Style::default().fg(p.accent).bg(p.panel_bg), + ); + } else if let Some(error) = remove.error.as_deref() { + put_text( + b, + inner.x, + inner.y + 5, + inner.width, + &format!(" {error}"), + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + let buttons = row(inner, &[18, 12], 2, 7); + let [primary, cancel] = buttons.as_slice() else { + return None; + }; + button( + b, + *primary, + if remove.force_confirmation { + " ↵ delete anyway " + } else { + " ↵ remove " + }, + Style::default() + .fg(contrast(p)) + .bg(p.red) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *primary, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: None, + ..OverlayRender::default() + }) +} diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs new file mode 100644 index 00000000..792cd248 --- /dev/null +++ b/src/client/shell/worktrees.rs @@ -0,0 +1,545 @@ +use super::*; + +impl ClientShellState { + fn endpoint_worktree_directory(&self) -> Option<std::path::PathBuf> { + self.snapshot + .as_deref() + .map(|snapshot| std::path::PathBuf::from(&snapshot.worktree_directory)) + } + + pub(super) fn insert_worktree_overlay_text(&mut self, text: &str) -> bool { + match self.overlay.as_mut() { + Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating => { + if create.replace_on_type { + create.branch.clear(); + create.replace_on_type = false; + } + create.branch.push_str(text); + self.sync_worktree_create_path(); + true + } + Some(ClientShellOverlay::WorktreeOpen(open)) + if open.search_focused && !open.opening => + { + open.query.push_str(text); + if let Some(first) = open.filtered_indices().first().copied() { + open.selected = first; + } + true + } + _ => false, + } + } + + pub(super) fn route_worktree_overlay_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> bool { + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + match self.overlay.as_ref() { + Some(ClientShellOverlay::WorktreeCreate(_)) => { + let creating = matches!( + self.overlay, + Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { creating: true, .. } + )) + ); + match code { + KeyCode::Esc if !creating => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Enter => self.submit_worktree_create(outcome), + KeyCode::Backspace if !creating => { + if let Some(ClientShellOverlay::WorktreeCreate(create)) = + self.overlay.as_mut() + { + if create.replace_on_type { + create.branch.clear(); + create.replace_on_type = false; + } else { + create.branch.pop(); + } + } + self.sync_worktree_create_path(); + outcome.repaint = true; + } + KeyCode::Char(character) + if !creating + && modifiers + .difference(crossterm::event::KeyModifiers::SHIFT) + .is_empty() => + { + let text = key + .generated_text + .clone() + .unwrap_or_else(|| character.to_string()); + self.insert_worktree_overlay_text(&text); + outcome.repaint = true; + } + _ => {} + } + true + } + Some(ClientShellOverlay::WorktreeOpen(_)) => { + let opening = matches!( + self.overlay, + Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { opening: true, .. } + )) + ); + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + search_focused: true, + .. + } + )) + ); + match code { + KeyCode::Esc if !opening => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Enter => self.submit_worktree_open(outcome), + KeyCode::Up if !opening => { + self.move_worktree_open_selection(-1); + outcome.repaint = true; + } + KeyCode::Down if !opening => { + self.move_worktree_open_selection(1); + outcome.repaint = true; + } + KeyCode::Char('/') if !opening && !search_focused => { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.search_focused = true; + } + outcome.repaint = true; + } + KeyCode::Backspace if !opening && search_focused => { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.query.pop(); + if let Some(first) = open.filtered_indices().first().copied() { + open.selected = first; + } + } + outcome.repaint = true; + } + KeyCode::Char(character) + if !opening + && search_focused + && modifiers + .difference(crossterm::event::KeyModifiers::SHIFT) + .is_empty() => + { + let text = key + .generated_text + .clone() + .unwrap_or_else(|| character.to_string()); + self.insert_worktree_overlay_text(&text); + outcome.repaint = true; + } + _ => {} + } + true + } + Some(ClientShellOverlay::WorktreeRemove(_)) => { + let removing = matches!( + self.overlay, + Some(ClientShellOverlay::WorktreeRemove( + ClientWorktreeRemoveOverlay { removing: true, .. } + )) + ); + match code { + KeyCode::Esc if !removing => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Enter => self.submit_worktree_remove(outcome), + _ => {} + } + true + } + _ => false, + } + } + + pub(super) fn begin_worktree_action( + &mut self, + action: crate::input::KeybindAction, + outcome: &mut ClientShellInput, + ) { + let Some(workspace_id) = self.workspace_action_id() else { + return; + }; + self.begin_worktree_action_for(action, workspace_id, outcome); + } + + pub(super) fn begin_worktree_action_for( + &mut self, + action: crate::input::KeybindAction, + workspace_id: String, + outcome: &mut ClientShellInput, + ) { + use crate::api::schema::{Method, WorktreeListParams}; + use crate::input::KeybindAction; + + let workspace = self.snapshot.as_deref().and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + }); + let linked = workspace + .and_then(|workspace| workspace.worktree.as_ref()) + .is_some_and(|worktree| worktree.is_linked_worktree); + let kind = match action { + KeybindAction::NewWorktree | KeybindAction::OpenWorktree if linked => { + self.endpoint_error = Some( + "New and open worktree actions start from the repo parent workspace." + .to_owned(), + ); + outcome.repaint = true; + return; + } + KeybindAction::NewWorktree => PendingEndpointKind::PrepareWorktreeCreate { + workspace_id: workspace_id.clone(), + }, + KeybindAction::OpenWorktree => PendingEndpointKind::PrepareWorktreeOpen { + workspace_id: workspace_id.clone(), + }, + KeybindAction::RemoveWorktree if !linked => { + self.endpoint_error = + Some("This workspace is not a Herdr-managed worktree checkout.".to_owned()); + outcome.repaint = true; + return; + } + KeybindAction::RemoveWorktree => PendingEndpointKind::PrepareWorktreeRemove { + workspace_id: workspace_id.clone(), + }, + _ => return, + }; + self.push_endpoint_method_with_kind( + Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id), + cwd: None, + trust_repository: false, + }), + kind, + outcome, + ); + } + + pub(super) fn sync_worktree_create_path(&mut self) { + let Some(worktree_directory) = self.endpoint_worktree_directory() else { + return; + }; + let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else { + return; + }; + create.checkout_path = crate::worktree::default_checkout_path( + &worktree_directory, + &create.repo_name, + &create.branch, + ) + .display() + .to_string(); + create.error = None; + } + + pub(super) fn submit_worktree_create(&mut self, outcome: &mut ClientShellInput) { + let Some(worktree_directory) = self.endpoint_worktree_directory() else { + return; + }; + let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else { + return; + }; + if create.creating { + return; + } + let branch = create.branch.trim().to_owned(); + if branch.is_empty() { + create.error = Some("branch is required".to_owned()); + outcome.repaint = true; + return; + } + create.branch = branch.clone(); + create.replace_on_type = false; + create.checkout_path = + crate::worktree::default_checkout_path(&worktree_directory, &create.repo_name, &branch) + .display() + .to_string(); + create.creating = true; + create.error = None; + let workspace_id = create.source_workspace_id.clone(); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::WorktreeCreate(crate::api::schema::WorktreeCreateParams { + workspace_id: Some(workspace_id), + cwd: None, + branch: Some(branch), + base: Some("HEAD".to_owned()), + path: None, + label: None, + focus: true, + trust_repository: false, + }), + PendingEndpointKind::WorktreeCreate, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn move_worktree_open_selection(&mut self, delta: isize) { + let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() else { + return; + }; + let filtered = open.filtered_indices(); + if filtered.is_empty() { + open.selected = 0; + return; + } + let current = filtered + .iter() + .position(|index| *index == open.selected) + .unwrap_or(0); + let next = (current as isize + delta).clamp(0, filtered.len() as isize - 1) as usize; + open.selected = filtered[next]; + } + + pub(super) fn submit_worktree_open(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() else { + return; + }; + if open.opening { + return; + } + let Some(index) = open.selected_entry_index() else { + return; + }; + let Some(entry) = open.entries.get(index) else { + return; + }; + let workspace_id = open.source_workspace_id.clone(); + let path = entry.path.clone(); + open.selected = index; + open.opening = true; + open.error = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::WorktreeOpen(crate::api::schema::WorktreeOpenParams { + workspace_id: Some(workspace_id), + cwd: None, + path: Some(path), + branch: None, + label: None, + focus: true, + trust_repository: false, + }), + PendingEndpointKind::WorktreeOpen, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn submit_worktree_remove(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() else { + return; + }; + if remove.removing { + return; + } + let workspace_id = remove.workspace_id.clone(); + let forced = remove.force_confirmation; + remove.removing = true; + remove.error = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::WorktreeRemove(crate::api::schema::WorktreeRemoveParams { + workspace_id, + force: forced, + trust_repository: false, + }), + PendingEndpointKind::WorktreeRemove { forced }, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn handle_worktree_endpoint_result( + &mut self, + kind: PendingEndpointKind, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + ) -> bool { + use crate::api::schema::ResponseResult; + + match (kind, result) { + ( + PendingEndpointKind::PrepareWorktreeCreate { workspace_id }, + Ok(ResponseResult::WorktreeList { source, .. }), + ) => { + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0); + let branch = crate::worktree::generated_branch_slug(seed); + let Some(worktree_directory) = self.endpoint_worktree_directory() else { + return false; + }; + let checkout_path = crate::worktree::default_checkout_path( + &worktree_directory, + &source.repo_name, + &branch, + ) + .display() + .to_string(); + self.overlay = Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { + source_workspace_id: workspace_id, + repo_name: source.repo_name, + branch, + checkout_path, + replace_on_type: true, + error: None, + creating: false, + }, + )); + true + } + ( + PendingEndpointKind::PrepareWorktreeOpen { workspace_id }, + Ok(ResponseResult::WorktreeList { worktrees, .. }), + ) => { + let entries = worktrees + .into_iter() + .filter(|entry| !entry.is_bare && !entry.is_prunable) + .map(|entry| { + let label = entry.branch.clone().unwrap_or_else(|| entry.label.clone()); + ClientWorktreeOpenEntry { + path: entry.path, + branch: entry.branch, + is_linked_worktree: entry.is_linked_worktree, + is_detached: entry.is_detached, + open_workspace_id: entry.open_workspace_id, + label, + } + }) + .collect::<Vec<_>>(); + if entries.is_empty() { + self.endpoint_error = Some("No Git worktrees found for this repo.".to_owned()); + } else { + self.overlay = Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + source_workspace_id: workspace_id, + entries, + selected: 0, + query: String::new(), + search_focused: false, + error: None, + opening: false, + }, + )); + } + true + } + ( + PendingEndpointKind::PrepareWorktreeRemove { workspace_id }, + Ok(ResponseResult::WorktreeList { worktrees, .. }), + ) => { + let path = worktrees + .into_iter() + .find(|entry| entry.open_workspace_id.as_deref() == Some(&workspace_id)) + .map(|entry| entry.path); + if let Some(path) = path { + self.overlay = Some(ClientShellOverlay::WorktreeRemove( + ClientWorktreeRemoveOverlay { + workspace_id, + path, + error: None, + removing: false, + force_confirmation: false, + }, + )); + } else { + self.endpoint_error = + Some("This workspace is not a Herdr-managed worktree checkout.".to_owned()); + } + true + } + (PendingEndpointKind::WorktreeCreate, Ok(ResponseResult::WorktreeCreated { .. })) + | (PendingEndpointKind::WorktreeOpen, Ok(ResponseResult::WorktreeOpened { .. })) + | ( + PendingEndpointKind::WorktreeRemove { .. }, + Ok(ResponseResult::WorktreeRemoved { .. }), + ) => { + self.overlay = None; + true + } + (PendingEndpointKind::WorktreeCreate, Err(error)) => { + if let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() { + create.creating = false; + create.error = Some(error.message); + } + true + } + (PendingEndpointKind::WorktreeOpen, Err(error)) => { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() { + open.opening = false; + open.error = Some(error.message); + } + true + } + (PendingEndpointKind::WorktreeRemove { forced: false }, Err(error)) + if error.code.as_deref() == Some("dirty_worktree_requires_force") => + { + if let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() { + remove.removing = false; + remove.force_confirmation = true; + remove.error = None; + } + true + } + (PendingEndpointKind::WorktreeRemove { .. }, Err(error)) => { + if let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() { + remove.removing = false; + remove.error = Some(error.message); + } + true + } + ( + PendingEndpointKind::PrepareWorktreeCreate { .. } + | PendingEndpointKind::PrepareWorktreeOpen { .. } + | PendingEndpointKind::PrepareWorktreeRemove { .. }, + Err(error), + ) => { + self.endpoint_error = Some(error.message); + true + } + (_, Ok(_)) => { + self.endpoint_error = + Some("endpoint returned an unexpected worktree result".to_owned()); + true + } + ( + PendingEndpointKind::Generic + | PendingEndpointKind::ProductAnnouncementDismiss { .. } + | PendingEndpointKind::ReleaseNotesDismiss + | PendingEndpointKind::PopupCommand + | PendingEndpointKind::ReloadConfig + | PendingEndpointKind::IntegrationList + | PendingEndpointKind::IntegrationInstall + | PendingEndpointKind::SelectionCopy { .. } + | PendingEndpointKind::PaneScroll { .. } + | PendingEndpointKind::WordSelection { .. } + | PendingEndpointKind::PaneLinkActivate { .. } + | PendingEndpointKind::CopyMotion { .. } + | PendingEndpointKind::CopySearch { .. }, + Err(error), + ) => { + self.endpoint_error = Some(error.message); + true + } + } + } +} |
