diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index 23718955..8bc06f10 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -1712,6 +1712,17 @@ "null" ] }, + "selection": { + "anyOf": [ + { + "$ref": "#/schemas/request/$defs/PaneSelectionReadParams" + }, + { + "type": "null" + } + ], + "description": "Client-owned selection coordinates, validated against the pane's content revision." + }, "tab_id": { "type": [ "string", @@ -2699,6 +2710,47 @@ }, "type": "object" }, + "PaneLinkActivateParams": { + "properties": { + "col": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "content_revision": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "pane_id": { + "type": "string" + }, + "viewport_row": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "pane_id", + "viewport_row", + "col" + ], + "type": "object" + }, "PaneListParams": { "properties": { "workspace_id": { @@ -5771,6 +5823,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "pane.link.activate", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneLinkActivateParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -10278,6 +10346,28 @@ ], "type": "object" }, + { + "properties": { + "handled": { + "type": "boolean" + }, + "type": { + "const": "pane_link_activated", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type", + "handled" + ], + "type": "object" + }, { "properties": { "logs": { diff --git a/src/api/schema.rs b/src/api/schema.rs index 18dcf284..074cd4ed 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -181,6 +181,8 @@ pub enum Method { PaneFocus(PaneTarget), #[serde(rename = "pane.input.set")] PaneInputSet(PaneInputSetParams), + #[serde(rename = "pane.link.activate")] + PaneLinkActivate(PaneLinkActivateParams), #[serde(rename = "pane.rename")] PaneRename(PaneRenameParams), #[serde(rename = "pane.send_text")] diff --git a/src/api/schema/commands.rs b/src/api/schema/commands.rs index 5c1b1046..c9ccd4bc 100644 --- a/src/api/schema/commands.rs +++ b/src/api/schema/commands.rs @@ -10,4 +10,7 @@ pub struct CommandInvokeParams { pub tab_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub pane_id: Option, + /// Client-owned selection coordinates, validated against the pane's content revision. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selection: Option, } diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs index 8e1b1c1d..15f56e6c 100644 --- a/src/api/schema/common.rs +++ b/src/api/schema/common.rs @@ -120,14 +120,6 @@ impl NotificationShowSound { pub fn is_none(&self) -> bool { matches!(self, Self::None) } - - pub fn to_sound(self) -> Option { - match self { - Self::None => None, - Self::Done => Some(crate::sound::Sound::Done), - Self::Request => Some(crate::sound::Sound::Request), - } - } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 4a60579b..517870f7 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -48,6 +48,17 @@ pub struct PaneInputSetParams { pub right_click: PaneRightClickTarget, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneLinkActivateParams { + pub pane_id: String, + pub viewport_row: u16, + pub col: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset_from_bottom: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum PaneDirection { diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index 32297c60..180d6645 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -274,6 +274,11 @@ pub enum ResponseResult { context: PluginInvocationContext, log: PluginCommandLogInfo, }, + PaneLinkActivated { + #[serde(default, skip_serializing_if = "Option::is_none")] + url: Option, + handled: bool, + }, PluginLogList { logs: Vec, }, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 47faec05..e4a7f2a3 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -313,6 +313,7 @@ fn command_invoke_request_round_trips_without_command_text() { workspace_id: Some("w1".into()), tab_id: Some("w1:t1".into()), pane_id: Some("w1:p1".into()), + selection: None, }), }; let json = serde_json::to_value(&request).unwrap(); @@ -1311,6 +1312,32 @@ fn event_wait_parses_typed_match() { ); } +#[test] +fn pane_link_activate_round_trips() { + let request = Request { + id: "req_pane_link".into(), + method: Method::PaneLinkActivate(PaneLinkActivateParams { + pane_id: "w1:p1".into(), + viewport_row: 3, + col: 7, + content_revision: Some(42), + offset_from_bottom: Some(5), + }), + }; + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "pane.link.activate"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); + + let response = ResponseResult::PaneLinkActivated { + url: Some("https://example.test".into()), + handled: false, + }; + let json = serde_json::to_string(&response).unwrap(); + let restored: ResponseResult = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); +} + #[test] fn plugin_action_list_and_invoke_round_trips() { let list = Request { diff --git a/src/api/server.rs b/src/api/server.rs index 06b643cf..ff0b6fdf 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -441,6 +441,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::PaneGet(_) => "pane.get", Method::PaneFocus(_) => "pane.focus", Method::PaneInputSet(_) => "pane.input.set", + Method::PaneLinkActivate(_) => "pane.link.activate", Method::PaneRename(_) => "pane.rename", Method::PaneSendText(_) => "pane.send_text", Method::PaneSendKeys(_) => "pane.send_keys", diff --git a/src/app/actions.rs b/src/app/actions.rs index 61d77ecd..85839322 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -3,7 +3,7 @@ use std::time::Instant; -use tracing::{info, warn}; +use tracing::warn; use crate::detect::{Agent, AgentState}; use crate::events::AppEvent; @@ -16,10 +16,8 @@ use crate::workspace::WorkspaceGitStatus; use super::api_helpers::pane_agent_status; use super::state::{ - navigator_display_index_of_row, navigator_display_lines, navigator_first_row_at_or_after, - text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow, - NavigatorStateFilter, NavigatorTarget, PaneFocusTarget, PendingAgentNotification, ToastKind, - ToastNotification, ToastTarget, ViewLayout, + AgentNotificationDelivery, AppState, Mode, PaneFocusTarget, PendingAgentNotification, + ToastKind, ToastNotification, ToastTarget, }; fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool { @@ -256,7 +254,7 @@ pub struct PaneStateUpdate { } // --------------------------------------------------------------------------- -// Navigator operations +// Focus tracking // --------------------------------------------------------------------------- impl AppState { @@ -270,18 +268,6 @@ impl AppState { }) } - pub(crate) fn pane_focus_target_indices( - &self, - target: &PaneFocusTarget, - ) -> Option<(usize, usize)> { - let ws_idx = self - .workspaces - .iter() - .position(|ws| ws.id == target.workspace_id)?; - let tab_idx = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id)?; - Some((ws_idx, tab_idx)) - } - pub(crate) fn record_pane_focus_change( &mut self, previous: Option, @@ -307,14 +293,6 @@ impl AppState { } } - fn sync_selection_after_focus_navigation(&mut self) { - if self.copy_mode.is_some() { - self.sync_copy_mode_with_focus(); - } else { - self.clear_selection(); - } - } - pub(crate) fn focus_pane_in_workspace(&mut self, ws_idx: usize, pane_id: PaneId) -> bool { let Some(ws) = self.workspaces.get(ws_idx) else { return false; @@ -331,9 +309,6 @@ impl AppState { return false; } - if self.copy_mode.is_some() { - self.clear_copy_mode_selection(); - } self.switch_workspace_tab(ws_idx, tab_idx); if let Some(tab) = self .workspaces @@ -343,649 +318,10 @@ impl AppState { tab.layout.focus_pane(pane_id); self.previous_pane_focus = previous; self.mark_session_dirty(); - self.sync_copy_mode_with_focus(); return true; } false } - - #[cfg(test)] - pub(crate) fn open_navigator(&mut self) { - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - self.open_navigator_from(&terminal_runtimes); - } - - pub(crate) fn open_navigator_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - self.navigator.query.clear(); - self.navigator.search_focused = false; - self.navigator.state_filter = None; - self.navigator.scroll = 0; - self.navigator.expanded_workspaces.clear(); - - for ws in &self.workspaces { - self.navigator.expanded_workspaces.insert(ws.id.clone()); - } - - self.mode = Mode::Navigator; - self.navigator.selected = self - .current_navigator_row_index_from(terminal_runtimes) - .unwrap_or(0); - self.ensure_navigator_selection_visible_from(terminal_runtimes); - } - - #[cfg(test)] - pub(crate) fn navigator_rows(&self) -> Vec { - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - self.navigator_rows_from(&terminal_runtimes) - } - - pub(crate) fn navigator_rows_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> Vec { - let query = self.navigator.query.trim().to_lowercase(); - let query_kind = navigator_query_kind(&query, self.navigator.state_filter); - let mut rows = Vec::new(); - for (ws_idx, ws) in self.workspaces.iter().enumerate() { - let workspace_label = ws.display_name_from(&self.terminals, terminal_runtimes); - let activity = workspace_activity_summary(ws, &self.terminals); - let workspace_search_text = format!("{workspace_label} {activity}").to_lowercase(); - let workspace_matches = match query_kind { - NavigatorQueryKind::Empty => true, - NavigatorQueryKind::State(filter) => { - let (state, seen) = ws.aggregate_state(&self.terminals); - navigator_state_filter_matches(filter, state, seen) - } - NavigatorQueryKind::Text => navigator_matches(&query, &workspace_search_text), - }; - - let child_rows = - self.navigator_child_rows(ws_idx, query_kind, &query, workspace_matches); - if !workspace_matches && child_rows.is_empty() { - continue; - } - - let expanded = !matches!(query_kind, NavigatorQueryKind::Empty) - || self.navigator.expanded_workspaces.contains(&ws.id); - let (state, seen) = ws.aggregate_state(&self.terminals); - let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::(); - rows.push(NavigatorRow { - target: NavigatorTarget::Workspace { ws_idx }, - depth: 0, - label: format!("{workspace_label} ({pane_count})"), - meta: activity, - status: state, - seen, - is_current: self.active == Some(ws_idx), - is_workspace: true, - is_tab: false, - expanded, - search_text: workspace_search_text, - matched: workspace_matches, - }); - if expanded { - rows.extend(child_rows); - } - } - rows - } - - fn navigator_child_rows( - &self, - ws_idx: usize, - query_kind: NavigatorQueryKind, - query: &str, - workspace_matches: bool, - ) -> Vec { - let Some(ws) = self.workspaces.get(ws_idx) else { - return Vec::new(); - }; - let multi_tab = ws.tabs.len() > 1; - let mut rows = Vec::new(); - for tab_idx in 0..ws.tabs.len() { - let mut tab_row = self.navigator_tab_row(ws_idx, tab_idx); - let tab_matches = match query_kind { - NavigatorQueryKind::Empty => true, - NavigatorQueryKind::State(filter) => { - navigator_state_filter_matches(filter, tab_row.status, tab_row.seen) - } - NavigatorQueryKind::Text => navigator_matches( - query, - if multi_tab { - &tab_row.search_text - } else { - &tab_row.label - }, - ), - }; - tab_row.matched = tab_matches; - let show_tab_row = - multi_tab || (matches!(query_kind, NavigatorQueryKind::Text) && tab_matches); - let mut pane_rows = self.navigator_pane_rows_for_tab(ws_idx, tab_idx, show_tab_row); - let filtered_panes = match query_kind { - NavigatorQueryKind::Empty => pane_rows, - NavigatorQueryKind::State(filter) => pane_rows - .into_iter() - .filter(|row| navigator_state_filter_matches(filter, row.status, row.seen)) - .collect::>(), - // A matching workspace or tab shows its whole subtree; panes - // keep their own match flag so context rows can be dimmed. - NavigatorQueryKind::Text if workspace_matches || tab_matches => { - for row in pane_rows.iter_mut() { - row.matched = navigator_matches(query, &row.search_text); - } - pane_rows - } - NavigatorQueryKind::Text => pane_rows - .into_iter() - .filter(|row| navigator_matches(query, &row.search_text)) - .collect::>(), - }; - - if show_tab_row && (tab_matches || !filtered_panes.is_empty()) { - rows.push(tab_row); - } - rows.extend(filtered_panes); - } - rows - } - - fn navigator_tab_row(&self, ws_idx: usize, tab_idx: usize) -> NavigatorRow { - let ws = &self.workspaces[ws_idx]; - let tab = &ws.tabs[tab_idx]; - let label = ws - .tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()); - let (status, seen) = tab_aggregate_state(tab, &self.terminals); - let activity = tab_activity_summary(tab, &self.terminals); - let pane_count = tab.panes.len(); - let meta = if activity.is_empty() { - format!("{pane_count} panes") - } else { - format!("{pane_count} panes · {activity}") - }; - let search_text = format!("{label} {meta}").to_lowercase(); - NavigatorRow { - target: NavigatorTarget::Tab { ws_idx, tab_idx }, - depth: 1, - label, - meta, - status, - seen, - is_current: false, - is_workspace: false, - is_tab: true, - expanded: true, - search_text, - matched: true, - } - } - - fn navigator_pane_rows_for_tab( - &self, - ws_idx: usize, - tab_idx: usize, - show_tab_row: bool, - ) -> Vec { - let Some(ws) = self.workspaces.get(ws_idx) else { - return Vec::new(); - }; - let Some(tab) = ws.tabs.get(tab_idx) else { - return Vec::new(); - }; - let mut rows = Vec::new(); - for pane_id in tab.layout.pane_ids() { - let Some(pane) = tab.panes.get(&pane_id) else { - continue; - }; - let terminal = self.terminals.get(&pane.attached_terminal_id); - let pane_number = ws.public_pane_number(pane_id).unwrap_or(0); - let label = terminal - .and_then(|terminal| terminal.effective_title()) - .or_else(|| { - terminal - .and_then(|terminal| terminal.manual_label.as_deref().map(str::to_string)) - }) - .or_else(|| { - terminal.and_then(|terminal| terminal.agent_name.as_deref().map(str::to_string)) - }) - .or_else(|| { - terminal - .and_then(|terminal| terminal.effective_agent_label().map(str::to_string)) - }) - .or_else(|| { - launch_label(terminal.and_then(|terminal| terminal.launch_argv.as_ref())) - }) - .unwrap_or_else(|| format!("pane {pane_number}")); - let display_agent = terminal.and_then(|terminal| terminal.effective_display_agent()); - let agent_label = display_agent.as_deref().or_else(|| { - terminal - .and_then(|terminal| terminal.agent_name.as_deref()) - .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label())) - }); - let state = terminal - .map(|terminal| terminal.state) - .unwrap_or(AgentState::Unknown); - let status_label = terminal - .map(|terminal| terminal.effective_presentation().state_labels) - .and_then(|labels| labels.get(state_label_text(state, pane.seen)).cloned()); - let status = status_label - .or_else(|| agent_label.map(|_| state_label_text(state, pane.seen).to_string())); - let meta = match (agent_label, status.as_deref()) { - (Some(agent_label), Some(status)) => format!("{agent_label} · {status}"), - (Some(agent_label), None) => agent_label.to_string(), - (None, _) => "shell".to_string(), - }; - let is_current = self.is_active_pane(ws_idx, tab_idx, pane_id); - let search_text = format!("{label} {meta}").to_lowercase(); - rows.push(NavigatorRow { - target: NavigatorTarget::Pane { - ws_idx, - tab_idx, - pane_id, - }, - depth: if show_tab_row { 2 } else { 1 }, - label, - meta, - status: state, - seen: pane.seen, - is_current, - is_workspace: false, - is_tab: false, - expanded: false, - search_text, - matched: true, - }); - } - rows - } - - fn current_navigator_row_index_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> Option { - let rows = self.navigator_rows_from(terminal_runtimes); - rows.iter() - .position(|row| matches!(row.target, NavigatorTarget::Pane { .. }) && row.is_current) - .or_else(|| rows.iter().position(|row| row.is_current)) - } - - pub(crate) fn ensure_navigator_selection_visible_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let body = self.navigator_body_rect(); - let viewport = body.height as usize; - if viewport == 0 { - self.navigator.scroll = 0; - return; - } - let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)); - let max_scroll = lines.len().saturating_sub(viewport); - let selected_line = - navigator_display_index_of_row(&lines, self.navigator.selected).unwrap_or(0); - if selected_line < self.navigator.scroll { - self.navigator.scroll = selected_line; - } else if selected_line >= self.navigator.scroll.saturating_add(viewport) { - self.navigator.scroll = selected_line.saturating_add(1).saturating_sub(viewport); - } - self.navigator.scroll = self.navigator.scroll.min(max_scroll); - } - - pub(crate) fn navigator_max_scroll_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - viewport: usize, - ) -> usize { - if viewport == 0 { - return 0; - } - navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)) - .len() - .saturating_sub(viewport) - } - - /// After a mouse-wheel scroll, snap the selection to the first selectable - /// row at or below the top of the viewport. - pub(crate) fn align_navigator_selection_to_scroll_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)); - if let Some(row_idx) = navigator_first_row_at_or_after(&lines, self.navigator.scroll) { - self.navigator.selected = row_idx; - } - self.clamp_navigator_selection_from(terminal_runtimes); - } - - pub(crate) fn move_navigator_selection_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - delta: isize, - ) { - let count = self.navigator_rows_from(terminal_runtimes).len(); - if count == 0 { - self.navigator.selected = 0; - self.navigator.scroll = 0; - return; - } - let current = self.navigator.selected.min(count - 1) as isize; - self.navigator.selected = (current + delta).clamp(0, count as isize - 1) as usize; - self.ensure_navigator_selection_visible_from(terminal_runtimes); - } - - /// Move the selection by a distance measured in display lines (used for - /// half-page jumps), landing on the nearest selectable row in the move - /// direction. - pub(crate) fn move_navigator_selection_by_lines_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - delta_lines: isize, - ) { - let rows = self.navigator_rows_from(terminal_runtimes); - if rows.is_empty() { - self.navigator.selected = 0; - self.navigator.scroll = 0; - return; - } - let lines = navigator_display_lines(&rows); - let current_line = - navigator_display_index_of_row(&lines, self.navigator.selected.min(rows.len() - 1)) - .unwrap_or(0); - let target_line = - (current_line as isize + delta_lines).clamp(0, lines.len() as isize - 1) as usize; - let row_idx = if delta_lines >= 0 { - navigator_first_row_at_or_after(&lines, target_line) - } else { - lines[..=target_line] - .iter() - .rev() - .find_map(|line| match line { - super::state::NavigatorDisplayLine::Row(idx) => Some(*idx), - super::state::NavigatorDisplayLine::Spacer => None, - }) - }; - if let Some(row_idx) = row_idx { - self.navigator.selected = row_idx; - } - self.ensure_navigator_selection_visible_from(terminal_runtimes); - } - - /// After the query or state filter changes, select the first row that - /// itself matched the filter, so enter immediately accepts the best match. - /// State filters prefer pane matches over aggregate workspace/tab matches. - pub(crate) fn select_first_navigator_match_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let query = self.navigator.query.trim().to_lowercase(); - let query_kind = navigator_query_kind(&query, self.navigator.state_filter); - if !matches!(query_kind, NavigatorQueryKind::Empty) { - let rows = self.navigator_rows_from(terminal_runtimes); - let idx = if matches!(query_kind, NavigatorQueryKind::State(_)) { - rows.iter() - .position(|row| { - row.matched && matches!(row.target, NavigatorTarget::Pane { .. }) - }) - .or_else(|| rows.iter().position(|row| row.matched)) - } else { - rows.iter().position(|row| row.matched) - }; - if let Some(idx) = idx { - self.navigator.selected = idx; - } - } - self.clamp_navigator_selection_from(terminal_runtimes); - } - - pub(crate) fn clamp_navigator_selection_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let count = self.navigator_rows_from(terminal_runtimes).len(); - self.navigator.selected = self.navigator.selected.min(count.saturating_sub(1)); - self.ensure_navigator_selection_visible_from(terminal_runtimes); - } - - pub(crate) fn toggle_selected_navigator_workspace_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let Some(row) = self - .navigator_rows_from(terminal_runtimes) - .get(self.navigator.selected) - .cloned() - else { - return; - }; - let NavigatorTarget::Workspace { ws_idx } = row.target else { - return; - }; - let Some(workspace_id) = self.workspaces.get(ws_idx).map(|ws| ws.id.clone()) else { - return; - }; - if self.navigator.expanded_workspaces.contains(&workspace_id) { - self.navigator.expanded_workspaces.remove(&workspace_id); - } else { - self.navigator.expanded_workspaces.insert(workspace_id); - } - self.clamp_navigator_selection_from(terminal_runtimes); - } - - #[cfg(test)] - pub(crate) fn accept_navigator_selection(&mut self) -> bool { - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - self.accept_navigator_selection_from(&terminal_runtimes) - } - - pub(crate) fn accept_navigator_selection_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> bool { - let Some(row) = self - .navigator_rows_from(terminal_runtimes) - .get(self.navigator.selected) - .cloned() - else { - return false; - }; - self.focus_navigator_target(row.target) - } - - pub(crate) fn focus_navigator_target(&mut self, target: NavigatorTarget) -> bool { - match target { - NavigatorTarget::Workspace { ws_idx } => { - if ws_idx >= self.workspaces.len() { - return false; - } - self.switch_workspace(ws_idx); - self.mode = Mode::Terminal; - true - } - NavigatorTarget::Tab { ws_idx, tab_idx } => { - if ws_idx >= self.workspaces.len() { - return false; - } - let tab_exists = self - .workspaces - .get(ws_idx) - .is_some_and(|ws| tab_idx < ws.tabs.len()); - if !tab_exists { - return false; - } - self.switch_workspace_tab(ws_idx, tab_idx); - self.mode = Mode::Terminal; - true - } - NavigatorTarget::Pane { - ws_idx, - tab_idx, - pane_id, - } => { - if ws_idx >= self.workspaces.len() { - return false; - } - if self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.tabs.get(tab_idx)) - .is_some_and(|tab| tab.panes.contains_key(&pane_id)) - { - self.focus_pane_in_workspace(ws_idx, pane_id); - self.mode = Mode::Terminal; - return true; - } - false - } - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum NavigatorQueryKind { - Empty, - Text, - State(NavigatorStateFilter), -} - -fn navigator_query_kind( - query: &str, - state_filter: Option, -) -> NavigatorQueryKind { - if let Some(filter) = state_filter { - return NavigatorQueryKind::State(filter); - } - if query.is_empty() { - NavigatorQueryKind::Empty - } else { - NavigatorQueryKind::Text - } -} - -fn navigator_state_filter_matches( - filter: NavigatorStateFilter, - state: AgentState, - seen: bool, -) -> bool { - match filter { - NavigatorStateFilter::Blocked => state == AgentState::Blocked, - NavigatorStateFilter::Working => state == AgentState::Working, - NavigatorStateFilter::Idle => state == AgentState::Idle && seen, - NavigatorStateFilter::Done => state == AgentState::Idle && !seen, - } -} - -fn navigator_matches(query: &str, text: &str) -> bool { - text_matches_query(query, text) -} - -fn launch_label(argv: Option<&Vec>) -> Option { - let argv = argv?; - let command = argv.first()?; - std::path::Path::new(command) - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_string) - .or_else(|| Some(command.clone())) -} - -fn state_label_text(state: AgentState, seen: bool) -> &'static str { - match (state, seen) { - (AgentState::Blocked, _) => "blocked", - (AgentState::Working, _) => "working", - (AgentState::Idle, false) => "done", - (AgentState::Idle, true) => "idle", - (AgentState::Unknown, _) => "unknown", - } -} - -fn tab_aggregate_state( - tab: &crate::workspace::Tab, - terminals: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalState, - >, -) -> (AgentState, bool) { - let mut aggregate = AgentState::Unknown; - let mut seen = true; - for pane in tab.panes.values() { - let Some(terminal) = terminals.get(&pane.attached_terminal_id) else { - continue; - }; - if state_priority(terminal.state, pane.seen) > state_priority(aggregate, seen) { - aggregate = terminal.state; - seen = pane.seen; - } - } - (aggregate, seen) -} - -fn state_priority(state: AgentState, seen: bool) -> u8 { - match (state, seen) { - (AgentState::Blocked, _) => 5, - (AgentState::Working, _) => 4, - (AgentState::Idle, false) => 3, - (AgentState::Idle, true) => 2, - (AgentState::Unknown, _) => 1, - } -} - -fn tab_activity_summary( - tab: &crate::workspace::Tab, - terminals: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalState, - >, -) -> String { - activity_summary_for_panes(tab.panes.values(), terminals) -} - -fn workspace_activity_summary( - ws: &crate::workspace::Workspace, - terminals: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalState, - >, -) -> String { - activity_summary_for_panes(ws.tabs.iter().flat_map(|tab| tab.panes.values()), terminals) -} - -fn activity_summary_for_panes<'a>( - panes: impl Iterator, - terminals: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalState, - >, -) -> String { - let mut blocked = 0usize; - let mut working = 0usize; - let mut done = 0usize; - for pane in panes { - let Some(terminal) = terminals.get(&pane.attached_terminal_id) else { - continue; - }; - match (terminal.state, pane.seen) { - (AgentState::Blocked, _) => blocked += 1, - (AgentState::Working, _) => working += 1, - (AgentState::Idle, false) => done += 1, - _ => {} - } - } - - let mut parts = Vec::new(); - if blocked > 0 { - parts.push(format!("{blocked} blocked")); - } - if working > 0 { - parts.push(format!("{working} working")); - } - if done > 0 { - parts.push(format!("{done} done")); - } - parts.join(" · ") } // --------------------------------------------------------------------------- @@ -1126,7 +462,6 @@ impl AppState { let workspace_id = self.workspaces[idx].id.clone(); crate::logging::workspace_focused(&workspace_id); self.mark_session_dirty(); - self.ensure_workspace_visible(idx); if let Some(ws) = self.workspaces.get_mut(idx) { let active_tab = ws.active_tab; ws.switch_tab(active_tab); @@ -1134,10 +469,7 @@ impl AppState { public_tab_id_for_index(ws, active_tab).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); } - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); - self.sync_selection_after_focus_navigation(); } } @@ -1162,99 +494,16 @@ impl AppState { crate::logging::workspace_focused(&workspace_id); } self.mark_session_dirty(); - self.ensure_workspace_visible(ws_idx); if let Some(ws) = self.workspaces.get_mut(ws_idx) { ws.switch_tab(tab_idx); let tab_id = public_tab_id_for_index(ws, tab_idx).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); } - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); - self.sync_selection_after_focus_navigation(); true } - pub(crate) fn ensure_workspace_visible(&mut self, idx: usize) { - if idx >= self.workspaces.len() { - return; - } - - if self.view.layout == ViewLayout::Mobile && self.mode == Mode::Navigate { - self.ensure_mobile_workspace_visible(idx); - return; - } - - if self.sidebar_collapsed { - return; - } - - let entries = crate::ui::workspace_list_entries(self); - let Some(target_entry_idx) = entries.iter().position(|entry| { - matches!( - entry, - crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == idx - ) - }) else { - return; - }; - - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - let mut cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); - if cards.iter().any(|card| card.ws_idx == idx) { - return; - } - - if target_entry_idx < self.workspace_scroll { - self.workspace_scroll = target_entry_idx; - return; - } - - while !cards.iter().any(|card| card.ws_idx == idx) { - let previous_scroll = self.workspace_scroll; - self.workspace_scroll = self.workspace_scroll.saturating_add(1); - if self.workspace_scroll == previous_scroll { - break; - } - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - if self.workspace_scroll == previous_scroll { - break; - } - cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); - if cards.is_empty() { - break; - } - } - } - - fn ensure_mobile_workspace_visible(&mut self, idx: usize) { - let viewport = crate::ui::mobile_switcher_areas(self).viewport; - if viewport.height == 0 { - return; - } - - let row_range = crate::ui::mobile_switcher_workspace_doc_range(self, idx); - let visible_start = self.mobile_switcher_scroll; - let visible_end = visible_start.saturating_add(viewport.height as usize); - if row_range.start < visible_start { - self.mobile_switcher_scroll = row_range.start; - } else if row_range.end > visible_end { - self.mobile_switcher_scroll = row_range.end.saturating_sub(viewport.height as usize); - } - self.mobile_switcher_scroll = self - .mobile_switcher_scroll - .min(crate::ui::mobile_switcher_max_scroll(self)); - } - #[cfg(test)] pub fn switch_tab(&mut self, idx: usize) { if let Some(ws_idx) = self.active { @@ -1267,10 +516,7 @@ impl AppState { let tab_id = public_tab_id_for_index(ws, idx).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); self.mark_session_dirty(); - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); - self.sync_selection_after_focus_navigation(); } } @@ -1296,77 +542,6 @@ impl AppState { changed } - pub(crate) fn visible_workspace_order(&self) -> Vec { - // Mobile always shows the worktree tree expanded, so its visible order - // must ignore collapse state to match what the switcher renders. - let entries = if self.view.layout == ViewLayout::Mobile { - crate::ui::workspace_list_entries_expanded(self) - } else { - crate::ui::workspace_list_entries(self) - }; - let order = entries - .into_iter() - .map(|entry| match entry { - crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } => ws_idx, - }) - .collect::>(); - if order.is_empty() { - (0..self.workspaces.len()).collect() - } else { - order - } - } - - pub(crate) fn workspace_at_visible_position(&self, position: usize) -> Option { - self.visible_workspace_order().get(position).copied() - } - - pub(crate) fn move_selected_workspace_by_visible_delta(&mut self, delta: isize) { - if self.workspaces.is_empty() { - return; - } - let order = self.visible_workspace_order(); - let current_pos = order - .iter() - .position(|idx| *idx == self.selected) - .unwrap_or(0); - let target_pos = current_pos - .saturating_add_signed(delta) - .min(order.len().saturating_sub(1)); - if let Some(ws_idx) = order.get(target_pos).copied() { - self.selected = ws_idx; - self.ensure_workspace_visible(ws_idx); - } - } - - #[cfg(test)] - pub fn next_workspace(&mut self) { - if self.workspaces.is_empty() { - return; - } - let current = self.active.unwrap_or(self.selected); - let order = self.visible_workspace_order(); - let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); - let next = order[(current_pos + 1) % order.len()]; - self.switch_workspace(next); - } - - #[cfg(test)] - pub fn previous_workspace(&mut self) { - if self.workspaces.is_empty() { - return; - } - let current = self.active.unwrap_or(self.selected); - let order = self.visible_workspace_order(); - let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); - let prev = if current_pos == 0 { - order[order.len() - 1] - } else { - order[current_pos - 1] - }; - self.switch_workspace(prev); - } - pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) -> bool { if source_idx >= self.workspaces.len() || insert_idx > self.workspaces.len() { return false; @@ -1396,7 +571,6 @@ impl AppState { self.selected = selected_id .and_then(|id| self.workspaces.iter().position(|ws| ws.id == id)) .unwrap_or(0); - self.ensure_workspace_visible(self.selected); true } @@ -1463,119 +637,9 @@ impl AppState { self.selected = selected_id .and_then(|id| self.workspaces.iter().position(|ws| ws.id == id)) .unwrap_or(0); - self.ensure_workspace_visible(self.selected); true } - pub fn scroll_tabs_left(&mut self) { - self.tab_scroll_follow_active = false; - self.tab_scroll = self.tab_scroll.saturating_sub(1); - self.refresh_tab_bar_view(); - } - - pub fn scroll_tabs_right(&mut self) { - self.tab_scroll_follow_active = false; - self.tab_scroll = self.tab_scroll.saturating_add(1); - self.refresh_tab_bar_view(); - } - - #[cfg(test)] - pub fn next_tab(&mut self) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if !ws.tabs.is_empty() { - let next = (ws.active_tab + 1) % ws.tabs.len(); - self.switch_tab(next); - } - } - } - - #[cfg(test)] - pub fn previous_tab(&mut self) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if !ws.tabs.is_empty() { - let prev = if ws.active_tab == 0 { - ws.tabs.len() - 1 - } else { - ws.active_tab - 1 - }; - self.switch_tab(prev); - } - } - } - - #[cfg(test)] - pub fn next_agent(&mut self) { - self.cycle_agent_entry(true); - } - - #[cfg(test)] - pub fn previous_agent(&mut self) { - self.cycle_agent_entry(false); - } - - #[cfg(test)] - pub fn focus_agent_entry(&mut self, idx: usize) -> bool { - let entries = crate::ui::agent_panel_entries(self); - let Some(target) = entries.get(idx) else { - return false; - }; - let ws_idx = target.ws_idx; - let pane_id = target.pane_id; - - if self.active == Some(ws_idx) && self.workspaces[ws_idx].focused_pane_id() == Some(pane_id) - { - self.ensure_agent_panel_entry_visible(idx); - return true; - } - - if self.focus_pane_in_workspace(ws_idx, pane_id) { - self.ensure_agent_panel_entry_visible(idx); - return true; - } - false - } - - #[cfg(test)] - fn cycle_agent_entry(&mut self, forward: bool) { - let entries = crate::ui::agent_panel_entries(self); - if entries.is_empty() { - return; - } - - let focused = self - .active - .and_then(|idx| self.workspaces.get(idx)) - .and_then(crate::workspace::Workspace::focused_pane_id); - let current_idx = - focused.and_then(|pane_id| entries.iter().position(|entry| entry.pane_id == pane_id)); - let target_idx = match (current_idx, forward) { - (Some(idx), true) => (idx + 1) % entries.len(), - (Some(0), false) => entries.len() - 1, - (Some(idx), false) => idx - 1, - (None, true) => 0, - (None, false) => entries.len() - 1, - }; - - self.focus_agent_entry(target_idx); - } - - pub(crate) fn ensure_agent_panel_entry_visible(&mut self, idx: usize) { - if self.sidebar_collapsed { - return; - } - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - self.view.sidebar_rect, - self.sidebar_section_split, - ); - self.agent_panel_scroll = crate::ui::agent_panel_scroll_for_target( - self, - detail_area, - self.agent_panel_scroll, - idx, - ); - } - pub(crate) fn terminal_ids_for_workspace( &self, ws_idx: usize, @@ -1657,7 +721,6 @@ impl AppState { pane_ids: impl IntoIterator, ) { let pane_ids = pane_ids.into_iter().collect::>(); - self.clear_copy_mode_for_removed_panes(pane_ids.iter().copied()); if self .previous_pane_focus .as_ref() @@ -1674,8 +737,6 @@ impl AppState { if self.workspaces.is_empty() { return; } - self.selection = None; - self.selection_autoscroll = None; self.mark_session_dirty(); let close_indices = self.workspace_close_indices(self.selected); @@ -1700,9 +761,6 @@ impl AppState { if self.workspaces.is_empty() { self.active = None; self.selected = 0; - self.workspace_scroll = 0; - self.tab_scroll = 0; - self.tab_scroll_follow_active = true; } else { // Keep focus on the previously focused workspace if let Some(id) = active_workspace_id { @@ -1714,39 +772,8 @@ impl AppState { self.selected = self.workspaces.len() - 1; } self.active = Some(self.selected); - self.workspace_scroll = self - .workspace_scroll - .min(self.workspaces.len().saturating_sub(1)); - self.ensure_workspace_visible(self.selected); - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); } } - - pub(crate) fn refresh_tab_bar_view(&mut self) { - let area = self.view.tab_bar_rect; - let Some(ws) = self.active.and_then(|idx| self.workspaces.get(idx)) else { - self.tab_scroll = 0; - self.view.tab_hit_areas.clear(); - self.view.tab_scroll_left_hit_area = ratatui::layout::Rect::default(); - self.view.tab_scroll_right_hit_area = ratatui::layout::Rect::default(); - self.view.new_tab_hit_area = ratatui::layout::Rect::default(); - return; - }; - - let layout = crate::ui::compute_tab_bar_view( - ws, - crate::ui::tab_bar_content_area(self, area), - self.tab_scroll, - self.tab_scroll_follow_active, - self.mouse_capture, - ); - self.tab_scroll = layout.scroll; - self.view.tab_hit_areas = layout.tab_hit_areas; - self.view.tab_scroll_left_hit_area = layout.scroll_left_hit_area; - self.view.tab_scroll_right_hit_area = layout.scroll_right_hit_area; - self.view.new_tab_hit_area = layout.new_tab_hit_area; - } } // --------------------------------------------------------------------------- @@ -1852,52 +879,6 @@ impl AppState { } } - #[cfg(test)] - pub fn cycle_pane(&mut self, reverse: bool) { - let Some(ws_idx) = self.active else { - return; - }; - let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else { - return; - }; - let ids = tab.layout.pane_ids(); - if let Some(pos) = ids.iter().position(|id| *id == tab.layout.focused()) { - let target = if reverse { - ids[(pos + ids.len() - 1) % ids.len()] - } else { - ids[(pos + 1) % ids.len()] - }; - self.focus_pane_in_workspace(ws_idx, target); - } - } - - #[cfg(test)] - pub fn last_pane(&mut self) { - let Some(target) = self.previous_pane_focus.clone() else { - return; - }; - let Some((ws_idx, tab_idx)) = self.pane_focus_target_indices(&target) else { - self.previous_pane_focus = None; - return; - }; - let current = self.current_pane_focus_target(); - if current.as_ref() == Some(&target) { - self.previous_pane_focus = None; - return; - } - - self.switch_workspace_tab(ws_idx, tab_idx); - if let Some(tab) = self - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) - { - tab.layout.focus_pane(target.pane_id); - self.previous_pane_focus = current; - self.mark_session_dirty(); - } - } - pub(crate) fn apply_pane_zoom( &mut self, ws_idx: usize, @@ -1991,31 +972,8 @@ impl AppState { self.workspace_close_indices(ws_idx).len() >= 2 } - pub(crate) fn begin_workspace_close_confirmation(&mut self, ws_idx: usize) -> bool { - let Some(workspace_id) = self - .workspaces - .get(ws_idx) - .map(|workspace| workspace.id.clone()) - else { - return false; - }; - self.selected = ws_idx; - self.confirm_close_workspace_id = Some(workspace_id); - self.mode = Mode::ConfirmClose; - true - } - - pub(crate) fn take_confirmed_workspace_close_index(&mut self) -> Option { - let workspace_id = self.confirm_close_workspace_id.take()?; - self.workspaces - .iter() - .position(|workspace| workspace.id == workspace_id) - } - - pub(crate) fn confirm_implicit_worktree_group_close(&mut self, ws_idx: usize) -> bool { - self.confirm_close - && self.workspace_close_would_close_worktree_group(ws_idx) - && self.begin_workspace_close_confirmation(ws_idx) + pub(crate) fn confirm_implicit_worktree_group_close(&self, ws_idx: usize) -> bool { + self.confirm_close && self.workspace_close_would_close_worktree_group(ws_idx) } #[cfg(test)] @@ -2052,8 +1010,6 @@ impl AppState { } } - self.selection = None; - self.selection_autoscroll = None; self.mark_session_dirty(); let terminal_ids = active .and_then(|i| { @@ -2099,8 +1055,6 @@ impl AppState { } } - self.selection = None; - self.selection_autoscroll = None; self.mark_session_dirty(); let should_close_workspace = self .active @@ -2134,176 +1088,70 @@ impl AppState { self.remove_plugin_pane_records(pane_ids); self.remove_unattached_terminal_ids(terminal_ids); crate::logging::tab_closed(&workspace_id, &closing_tab_id); - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); } false } } -// --------------------------------------------------------------------------- -// Selection -// --------------------------------------------------------------------------- - impl AppState { - pub fn clear_selection(&mut self) { - self.selection = None; - self.selection_autoscroll = None; - } - - pub(crate) fn stop_selection_autoscroll_state(&mut self) { - self.selection_autoscroll = None; - } - - pub(crate) fn select_word_at_pane_cell( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - viewport_row: u16, - col: u16, - ) -> bool { - // Resolve the active pane cell the double-click landed on. - let Some(ws_idx) = self - .active - .filter(|idx| self.workspaces.get(*idx).is_some()) - else { - return false; - }; - - let Some(info) = self.pane_info_by_id(pane_id) else { - return false; - }; - if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width { - return false; - } - - // Leave mouse input to terminal apps that requested it. - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - else { - return false; - }; - if rt.mouse_reporting_enabled() { - return false; - } - - // Read the visible row and identify the clicked token bounds. - let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - let row_selection = Selection::range( - pane_id, - viewport_row, - 0, - info.inner_rect.width.saturating_sub(1), - metrics, - ); - let Some(row_text) = rt.extract_selection(&row_selection) else { - return false; - }; - let Some((start_col, end_col)) = word_bounds_at_column(&row_text, col) else { - return false; - }; - - let mut selection = Selection::range(pane_id, viewport_row, start_col, end_col, metrics); - if !selection.finish() { - return false; - } - - let text = if self.copy_on_select { - let Some(text) = rt - .extract_selection(&selection) - .filter(|text| !text.is_empty()) - else { - self.clear_selection(); - return false; - }; - Some(text) - } else { - None - }; - - self.selection = Some(selection); - self.selection_autoscroll = None; - if let Some(text) = text { - self.request_clipboard_write = Some(text.into_bytes()); - info!("copied double-clicked token to clipboard"); - } - true - } - - pub(crate) fn url_at_pane_cell( + pub(crate) fn url_at_pane_surface_cell( &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + ws_idx: usize, pane_id: crate::layout::PaneId, viewport_row: u16, col: u16, ) -> Option { - let ws_idx = self - .active - .filter(|idx| self.workspaces.get(*idx).is_some())?; - let info = self.pane_info_by_id(pane_id)?; - if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width { - return None; - } - let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; - let screen_col = info.inner_rect.x.saturating_add(col); - let screen_row = info.inner_rect.y.saturating_add(viewport_row); - if let Some((_, _, uri)) = rt - .visible_hyperlinks(info.inner_rect) - .into_iter() - .find(|((x, y), _, _)| *x == screen_col && *y == screen_row) - { - return Some(uri); - } - - let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - let visible_selection = Selection::line_range( + let (height, width) = rt.current_size(); + url_at_runtime_cell( + rt, pane_id, - crate::selection::absolute_row_for_viewport(0, metrics), - crate::selection::absolute_row_for_viewport( - info.inner_rect.height.saturating_sub(1), - metrics, - ), - info.inner_rect.width.saturating_sub(1), - ); - let visible_text = rt.extract_selection(&visible_selection)?; - let logical_cell = - logical_cell_for_visible_cell(&visible_text, info.inner_rect.width, viewport_row, col)?; - let line_start = visible_text[..logical_cell.byte_index] - .rfind('\n') - .map_or(0, |idx| idx + 1); - let line_end = visible_text[logical_cell.byte_index..] - .find('\n') - .map_or(visible_text.len(), |idx| logical_cell.byte_index + idx); - let line = visible_text.get(line_start..line_end)?; - url_at_column(line, logical_cell.logical_col).map(str::to_owned) + ratatui::layout::Rect::new(0, 0, width, height), + viewport_row, + col, + rt.scroll_metrics(), + ) + } +} + +fn url_at_runtime_cell( + runtime: &crate::terminal::TerminalRuntime, + pane_id: crate::layout::PaneId, + area: ratatui::layout::Rect, + viewport_row: u16, + col: u16, + metrics: Option, +) -> Option { + if viewport_row >= area.height || col >= area.width { + return None; + } + let screen_col = area.x.saturating_add(col); + let screen_row = area.y.saturating_add(viewport_row); + if let Some((_, _, uri)) = runtime + .visible_hyperlinks(area) + .into_iter() + .find(|((x, y), _, _)| *x == screen_col && *y == screen_row) + { + return Some(uri); } - pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) { - let mut sel = match self.selection.take() { - Some(sel) => sel, - None => return, - }; - if !sel.is_finalized() && !sel.finish() { - return; - } - - let ws_idx = match self.active { - Some(ws_idx) if self.workspaces.get(ws_idx).is_some() => ws_idx, - _ => return, - }; - - let text = self - .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, sel.pane_id) - .and_then(|rt| rt.extract_selection(&sel)); - if let Some(text) = text { - if !text.is_empty() { - self.request_clipboard_write = Some(text.into_bytes()); - info!("copied selection to clipboard"); - } - } - - self.clear_selection(); - } + let visible_selection = Selection::line_range( + pane_id, + crate::selection::absolute_row_for_viewport(0, metrics), + crate::selection::absolute_row_for_viewport(area.height.saturating_sub(1), metrics), + area.width.saturating_sub(1), + ); + let visible_text = runtime.extract_selection(&visible_selection)?; + let logical_cell = logical_cell_for_visible_cell(&visible_text, area.width, viewport_row, col)?; + let line_start = visible_text[..logical_cell.byte_index] + .rfind('\n') + .map_or(0, |idx| idx + 1); + let line_end = visible_text[logical_cell.byte_index..] + .find('\n') + .map_or(visible_text.len(), |idx| logical_cell.byte_index + idx); + let line = visible_text.get(line_start..line_end)?; + url_at_column(line, logical_cell.logical_col).map(str::to_owned) } pub(crate) fn safe_web_url(url: &str) -> Option<&str> { @@ -2928,7 +1776,6 @@ impl AppState { // foreground client; they never touch AppState. Kept for AppEvent exhaustiveness. AppEvent::TerminalBell { .. } => Vec::new(), AppEvent::ClipboardWrite { .. } => Vec::new(), - AppEvent::PrefixInputSource { .. } => Vec::new(), AppEvent::TerminalCwdReported { pane_id, cwd } => { if !cwd.is_absolute() || !cwd.is_dir() { return Vec::new(); @@ -3319,15 +2166,6 @@ impl AppState { return; }; - if self - .selection - .as_ref() - .is_some_and(|s| s.pane_id == pane_id) - { - self.selection = None; - self.selection_autoscroll = None; - } - let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id); let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx); self.pane_id_aliases.retain(|_, alias| *alias != pane_id); @@ -3373,10 +2211,6 @@ impl AppState { if self.selected >= self.workspaces.len() { self.selected = self.workspaces.len() - 1; } - self.workspace_scroll = self - .workspace_scroll - .min(self.workspaces.len().saturating_sub(1)); - self.ensure_workspace_visible(self.selected); } } else { self.remove_unattached_terminal_ids(pane_terminal_id); @@ -3635,412 +2469,6 @@ mod tests { assert_eq!(selected_url("open file:///tmp/report", "file"), None); } - #[test] - fn navigator_rows_show_tab_nodes_only_for_multi_tab_workspaces() { - let mut state = app_with_workspaces(&["single", "multi"]); - state.workspaces[1].test_add_tab(Some("tests")); - state.ensure_test_terminals(); - - state.open_navigator(); - let rows = state.navigator_rows(); - - assert!(!rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. } - ))); - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { - ws_idx: 1, - tab_idx: 0 - } - ))); - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { - ws_idx: 1, - tab_idx: 1 - } - ))); - } - - #[test] - fn navigator_search_matches_named_tabs_in_single_tab_workspaces() { - let mut state = app_with_workspaces(&["multi", "single"]); - state.workspaces[0].tabs[0].custom_name = Some("Foo".into()); - state.workspaces[0].test_add_tab(Some("Bar")); - state.workspaces[1].tabs[0].custom_name = Some("Baz".into()); - state.ensure_test_terminals(); - - state.open_navigator(); - state.navigator.query = "foo".into(); - assert!(state.navigator_rows().iter().any(|row| { - row.matched - && matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { - ws_idx: 0, - tab_idx: 0 - } - ) - })); - - state.navigator.query = "baz".into(); - state.select_first_navigator_match_from(&crate::terminal::TerminalRuntimeRegistry::new()); - let rows = state.navigator_rows(); - assert!(rows - .get(state.navigator.selected) - .is_some_and(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { - ws_idx: 1, - tab_idx: 0 - } - ))); - assert!(!rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Workspace { ws_idx: 0 } - | crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. } - | crate::app::state::NavigatorTarget::Pane { ws_idx: 0, .. } - ))); - - assert!(state.accept_navigator_selection()); - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].active_tab_index(), 0); - assert_eq!(state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn navigator_rows_match_live_root_runtime_cwd_workspace_label() { - let unique = format!( - "herdr-navigator-runtime-cwd-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - let root = std::env::temp_dir().join(unique); - let stale_cwd = root.join("issue-264-nix-support"); - let live_cwd = root.join("herdr"); - std::fs::create_dir_all(stale_cwd.join(".git")).unwrap(); - std::fs::create_dir_all(live_cwd.join(".git")).unwrap(); - - let mut state = AppState::test_new(); - let mut workspace = Workspace::test_new("stale-name"); - workspace.custom_name = None; - workspace.identity_cwd = stale_cwd.clone(); - let pane = workspace.tabs[0].root_pane; - state.workspaces = vec![workspace]; - state.ensure_test_terminals(); - let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap(); - state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; - - let (events, _) = tokio::sync::mpsc::channel(4); - let runtime = crate::terminal::TerminalRuntime::spawn( - pane, - 24, - 80, - live_cwd.clone(), - 0, - crate::terminal_theme::TerminalTheme::default(), - None, - crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), - &crate::pane::PaneLaunchEnv::default(), - events, - std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(crate::render_signal::RenderSignal::new()), - ) - .unwrap(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - let mut runtime_registry = crate::terminal::TerminalRuntimeRegistry::new(); - runtime_registry.insert(terminal_id, runtime); - state.open_navigator_from(&runtime_registry); - state.navigator.query = "herdr".into(); - let rows = state.navigator_rows_from(&runtime_registry); - - for (_, runtime) in runtime_registry.drain() { - runtime.shutdown(); - } - let _ = std::fs::remove_dir_all(root); - - // The workspace matched by its live cwd label; its subtree cascades in - // as context. - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].label, "herdr (1)"); - assert!(rows[0].matched); - assert!(!rows[1].matched); - } - - #[test] - fn navigator_rows_include_shell_and_agent_panes() { - let mut state = app_with_workspaces(&["one"]); - let shell = state.workspaces[0].tabs[0].root_pane; - let agent = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - - let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap(); - let terminal = state.terminals.get_mut(&agent_terminal_id).unwrap(); - terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); - - state.open_navigator(); - let rows = state.navigator_rows(); - - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell - ))); - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent - ) && row.meta.contains("claude"))); - } - - #[test] - fn opening_navigator_selects_current_pane_and_expands_attention_workspaces() { - let mut state = app_with_workspaces(&["one", "two"]); - let blocked = state.workspaces[1].tabs[0].root_pane; - let blocked_terminal_id = state.workspaces[1].terminal_id(blocked).cloned().unwrap(); - state - .terminals - .get_mut(&blocked_terminal_id) - .unwrap() - .set_detected_state(Some(Agent::Codex), AgentState::Blocked); - - state.open_navigator(); - let selected = state.navigator_rows()[state.navigator.selected].clone(); - - assert!(selected.is_current); - assert!(state - .navigator - .expanded_workspaces - .contains(&state.workspaces[0].id)); - assert!(state - .navigator - .expanded_workspaces - .contains(&state.workspaces[1].id)); - } - - #[test] - fn accepting_navigator_pane_switches_workspace_tab_and_focus() { - let mut state = app_with_workspaces(&["one", "two"]); - let target = state.workspaces[1].tabs[0].root_pane; - state.open_navigator(); - state - .navigator - .expanded_workspaces - .insert(state.workspaces[1].id.clone()); - state.navigator.selected = state - .navigator_rows() - .iter() - .position(|row| { - matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == target - ) - }) - .unwrap(); - - assert!(state.accept_navigator_selection()); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(target)); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn navigator_idle_search_matches_idle_agents_not_plain_shells() { - let mut state = app_with_workspaces(&["one"]); - let shell = state.workspaces[0].tabs[0].root_pane; - let agent = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - - let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap(); - state - .terminals - .get_mut(&agent_terminal_id) - .unwrap() - .set_detected_state(Some(Agent::Claude), AgentState::Idle); - - state.open_navigator(); - state.navigator.query = "idle".into(); - let rows = state.navigator_rows(); - - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent - ))); - assert!(!rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell - ))); - } - - #[test] - fn navigator_search_only_matches_visible_row_text() { - let mut state = app_with_workspaces(&["one"]); - state.workspaces[0].identity_cwd = "/tmp/herdr-worktrees/issue-work".into(); - - state.open_navigator(); - state.navigator.query = "work".into(); - - assert!(state.navigator_rows().is_empty()); - } - - #[test] - fn navigator_state_filter_is_separate_from_text_search() { - let mut state = app_with_workspaces(&["one"]); - let shell = state.workspaces[0].tabs[0].root_pane; - let working = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - - let shell_terminal_id = state.workspaces[0].terminal_id(shell).cloned().unwrap(); - state - .terminals - .get_mut(&shell_terminal_id) - .unwrap() - .set_manual_label("wheel notes".into()); - let working_terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap(); - state - .terminals - .get_mut(&working_terminal_id) - .unwrap() - .set_detected_state(Some(Agent::Codex), AgentState::Working); - - state.open_navigator(); - state.navigator.state_filter = Some(NavigatorStateFilter::Working); - let state_rows = state.navigator_rows(); - - assert!(state_rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working - ))); - assert!(!state_rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell - ))); - - state.navigator.state_filter = None; - state.navigator.query = "w".into(); - let text_rows = state.navigator_rows(); - - assert!(text_rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell - ))); - assert!( - text_rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working - )), - "literal one-letter search may still match visible state text" - ); - } - - #[test] - fn navigator_search_filters_panes_but_keeps_workspace_context() { - let mut state = app_with_workspaces(&["one"]); - let root = state.workspaces[0].tabs[0].root_pane; - let terminal_id = state.workspaces[0].terminal_id(root).cloned().unwrap(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_manual_label("weekly review".into()); - state.open_navigator(); - state.navigator.query = "weekly".into(); - - let rows = state.navigator_rows(); - - assert!(rows.iter().any(|row| row.is_workspace)); - assert!(rows - .iter() - .any(|row| !row.is_workspace && row.label.contains("weekly"))); - } - - #[test] - fn navigator_workspace_match_cascades_full_subtree() { - let mut state = app_with_workspaces(&["one", "two"]); - let root = state.workspaces[0].tabs[0].root_pane; - let extra = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - for pane in [root, extra] { - let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_manual_label("unrelated".into()); - } - - state.open_navigator(); - state.navigator.query = "one".into(); - let rows = state.navigator_rows(); - - // Both panes cascade in even though only the workspace label matched, - // and only the workspace carries the matched flag. - let pane_rows: Vec<_> = rows.iter().filter(|row| !row.is_workspace).collect(); - assert_eq!(pane_rows.len(), 2); - assert!(pane_rows.iter().all(|row| !row.matched)); - assert!(rows.iter().any(|row| row.is_workspace && row.matched)); - assert!(!rows.iter().any(|row| row.label.starts_with("two"))); - } - - #[test] - fn navigator_search_selects_first_self_match() { - let mut state = app_with_workspaces(&["one", "two"]); - let pane = state.workspaces[1].tabs[0].root_pane; - state.ensure_test_terminals(); - let terminal_id = state.workspaces[1].terminal_id(pane).cloned().unwrap(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_manual_label("pi ui build".into()); - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.open_navigator_from(&terminal_runtimes); - state.navigator.query = "ui".into(); - state.select_first_navigator_match_from(&terminal_runtimes); - - let rows = state.navigator_rows_from(&terminal_runtimes); - let selected = &rows[state.navigator.selected]; - assert!(matches!( - selected.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == pane - )); - } - - #[test] - fn navigator_state_filter_selects_matching_pane_over_workspace() { - let mut state = app_with_workspaces(&["one"]); - let working = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - let terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_detected_state(Some(Agent::Codex), AgentState::Working); - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.open_navigator_from(&terminal_runtimes); - state.navigator.state_filter = Some(NavigatorStateFilter::Working); - state.select_first_navigator_match_from(&terminal_runtimes); - - let rows = state.navigator_rows_from(&terminal_runtimes); - let selected = &rows[state.navigator.selected]; - assert!(matches!( - selected.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working - )); - } - #[test] fn apply_workspace_git_statuses_updates_matching_workspace() { let mut state = app_with_workspaces(&["one", "two"]); @@ -4188,196 +2616,6 @@ mod tests { assert_eq!(state.workspaces[0].worktree_space().cloned(), membership); } - fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) { - set_agent_state(state, ws_idx, tab_idx, pane_id, AgentState::Idle); - } - - fn set_agent_state( - state: &mut AppState, - ws_idx: usize, - tab_idx: usize, - pane_id: PaneId, - agent_state: AgentState, - ) { - state.ensure_test_terminals(); - let terminal_id = state.workspaces[ws_idx].tabs[tab_idx] - .panes - .get(&pane_id) - .unwrap() - .attached_terminal_id - .clone(); - if let Some(terminal) = state.terminals.get_mut(&terminal_id) { - terminal.set_detected_state(Some(Agent::Pi), agent_state); - } - } - - fn transition_agent_state(state: &mut AppState, pane_id: PaneId, agent_state: AgentState) { - state - .update_terminal_state(pane_id, |terminal| { - Some(terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - agent_state, - matches!(agent_state, AgentState::Blocked), - false, - false, - false, - std::time::Instant::now(), - )) - }) - .expect("agent state transition should update pane state"); - } - - #[test] - fn next_agent_cycles_agent_panel_entries() { - let mut first = Workspace::test_new("one"); - let first_root = first.tabs[0].root_pane; - let first_second = first.test_split(Direction::Horizontal); - first.tabs[0].layout.focus_pane(first_root); - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - - let mut state = AppState::test_new(); - state.workspaces = vec![first, second]; - state.ensure_test_terminals(); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - mark_agent(&mut state, 0, 0, first_root); - mark_agent(&mut state, 0, 0, first_second); - mark_agent(&mut state, 1, 0, second_root); - - state.next_agent(); - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second)); - - state.next_agent(); - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); - - state.previous_agent(); - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second)); - state.assert_invariants_for_test(); - } - - #[test] - fn focus_agent_entry_uses_agent_panel_order() { - let mut first = Workspace::test_new("one"); - let first_root = first.tabs[0].root_pane; - let first_second = first.test_split(Direction::Horizontal); - first.tabs[0].layout.focus_pane(first_root); - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - - let mut state = AppState::test_new(); - state.workspaces = vec![first, second]; - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - mark_agent(&mut state, 0, 0, first_root); - mark_agent(&mut state, 0, 0, first_second); - mark_agent(&mut state, 1, 0, second_root); - - assert!(state.focus_agent_entry(2)); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); - state.assert_invariants_for_test(); - } - - #[test] - fn focus_agent_entry_succeeds_for_already_focused_agent() { - let mut state = app_with_workspaces(&["one"]); - let root = state.workspaces[0].tabs[0].root_pane; - mark_agent(&mut state, 0, 0, root); - - assert!(state.focus_agent_entry(0)); - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - state.assert_invariants_for_test(); - } - - #[test] - fn next_agent_cycles_priority_sorted_agent_panel_entries() { - let mut first = Workspace::test_new("one"); - let first_root = first.tabs[0].root_pane; - let first_second = first.test_split(Direction::Horizontal); - first.tabs[0].layout.focus_pane(first_root); - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - - let mut state = AppState::test_new(); - state.workspaces = vec![first, second]; - state.ensure_test_terminals(); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; - set_agent_state(&mut state, 0, 0, first_root, AgentState::Idle); - set_agent_state(&mut state, 0, 0, first_second, AgentState::Working); - set_agent_state(&mut state, 1, 0, second_root, AgentState::Blocked); - - state.next_agent(); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); - state.assert_invariants_for_test(); - } - - #[test] - fn priority_sort_keeps_recently_changed_idle_agent_above_older_idle_agent() { - let mut workspace = Workspace::test_new("one"); - let first = workspace.tabs[0].root_pane; - let second = workspace.test_split(Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(first); - - let mut state = AppState::test_new(); - state.workspaces = vec![workspace]; - state.ensure_test_terminals(); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; - - transition_agent_state(&mut state, first, AgentState::Idle); - transition_agent_state(&mut state, second, AgentState::Working); - assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second); - - transition_agent_state(&mut state, second, AgentState::Idle); - - assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second); - state.assert_invariants_for_test(); - } - - #[test] - fn previous_agent_keeps_wrapped_target_visible_in_agent_panel() { - let mut workspace = Workspace::test_new("one"); - let root = workspace.tabs[0].root_pane; - for idx in 1..8 { - workspace.test_add_tab(Some(&format!("tab-{idx}"))); - } - - let mut state = AppState::test_new(); - state.workspaces = vec![workspace]; - state.ensure_test_terminals(); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - for tab_idx in 0..state.workspaces[0].tabs.len() { - let pane_id = state.workspaces[0].tabs[tab_idx].root_pane; - mark_agent(&mut state, 0, tab_idx, pane_id); - } - state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); - - state.previous_agent(); - - let last_idx = state.workspaces[0].tabs.len() - 1; - assert_eq!(state.workspaces[0].active_tab, last_idx); - assert!(state.agent_panel_scroll > 0); - state.assert_invariants_for_test(); - } - #[test] fn switch_workspace_updates_active_and_selected() { let mut state = app_with_workspaces(&["a", "b", "c"]); @@ -4386,137 +2624,6 @@ mod tests { assert_eq!(state.selected, 2); } - #[test] - fn last_pane_toggles_to_previous_focus_in_active_tab() { - let mut state = app_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - - state.focus_pane_in_workspace(0, root); - state.focus_pane_in_workspace(0, right); - state.last_pane(); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - - state.last_pane(); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); - } - - #[test] - fn removing_background_pane_preserves_last_pane_history() { - let mut state = app_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - let background = state.workspaces[0].test_split(Direction::Horizontal); - - state.focus_pane_in_workspace(0, root); - state.focus_pane_in_workspace(0, right); - state.workspaces[0].remove_pane(background); - state.last_pane(); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - } - - #[test] - fn last_pane_jumps_across_workspaces_and_tabs() { - let mut state = app_with_workspaces(&["one", "two"]); - let first_root = state.workspaces[0].tabs[0].root_pane; - let second_tab = state.workspaces[1].test_add_tab(Some("logs")); - let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane; - - state.focus_pane_in_workspace(0, first_root); - state.focus_pane_in_workspace(1, second_tab_root); - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].active_tab, 0); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); - - state.last_pane(); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].active_tab, second_tab); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root)); - } - - #[test] - fn last_pane_tracks_tab_and_workspace_switches() { - let mut state = app_with_workspaces(&["one", "two"]); - let first_root = state.workspaces[0].tabs[0].root_pane; - let first_second_tab = state.workspaces[0].test_add_tab(Some("logs")); - let first_second_root = state.workspaces[0].tabs[first_second_tab].root_pane; - let second_root = state.workspaces[1].tabs[0].root_pane; - - state.switch_tab(first_second_tab); - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].active_tab, 0); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); - - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].active_tab, first_second_tab); - assert_eq!( - state.workspaces[0].focused_pane_id(), - Some(first_second_root) - ); - - state.switch_workspace(1); - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].active_tab, first_second_tab); - assert_eq!( - state.workspaces[0].focused_pane_id(), - Some(first_second_root) - ); - - state.last_pane(); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); - } - - #[test] - fn last_pane_tracks_cross_workspace_tab_selection() { - let mut state = app_with_workspaces(&["one", "two"]); - let first_root = state.workspaces[0].tabs[0].root_pane; - let second_first_root = state.workspaces[1].tabs[0].root_pane; - let second_tab = state.workspaces[1].test_add_tab(Some("logs")); - let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane; - - state.switch_workspace_tab(1, second_tab); - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); - - state.last_pane(); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].active_tab, second_tab); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root)); - assert_ne!(second_first_root, second_tab_root); - } - - #[test] - fn switch_workspace_keeps_selected_visible_in_scrolled_sidebar() { - let mut state = app_with_workspaces(&["a", "b", "c", "d", "e", "f", "g", "h"]); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); - - state.switch_workspace(7); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); - - assert!(state - .view - .workspace_card_areas - .iter() - .any(|card| card.ws_idx == 7)); - } - #[test] fn switch_workspace_marks_panes_seen() { let mut state = app_with_workspaces(&["a", "b"]); @@ -4706,34 +2813,6 @@ mod tests { state.assert_invariants_for_test(); } - #[test] - fn pane_died_self_closing_earlier_workspace_keeps_focus() { - let names = (0..20).map(|i| format!("ws{i:02}")).collect::>(); - let name_refs = names.iter().map(String::as_str).collect::>(); - let mut state = app_with_workspaces(&name_refs); - state.selected = 1; - state.active = Some(1); - // Constrain the sidebar viewport so the focused workspace starts - // off-screen: 20 workspaces cannot all fit in 12 rows, so index 1 is - // hidden at maximum scroll regardless of individual card height. - state.view.sidebar_rect = ratatui::layout::Rect::new(0, 0, 30, 12); - state.workspace_scroll = - crate::ui::normalized_workspace_scroll(&state, state.view.sidebar_rect, usize::MAX / 2); - let cards = crate::ui::compute_workspace_card_areas(&state, state.view.sidebar_rect); - assert!(cards.iter().all(|card| card.ws_idx != 1)); - - let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); - state.handle_pane_died(pane_id); - - assert_eq!(state.workspaces.len(), 19); - assert_eq!(state.workspaces[0].display_name(), "ws01"); - assert_eq!(state.selected, 0); - assert_eq!(state.active, Some(0)); - let cards = crate::ui::compute_workspace_card_areas(&state, state.view.sidebar_rect); - assert!(cards.iter().any(|card| card.ws_idx == 0)); - state.assert_invariants_for_test(); - } - #[test] fn pane_died_last_pane_removes_workspace() { let mut state = app_with_workspaces(&["a", "b"]); @@ -4782,55 +2861,6 @@ mod tests { assert_eq!(state.workspaces.len(), 1); state.assert_invariants_for_test(); } - - #[test] - fn pane_died_unrelated_pane_preserves_selection() { - // Two workspaces; user is selecting text in workspace 0. - // A pane in workspace 1 dies — selection must be preserved. - let mut state = app_with_workspaces(&["active", "bg"]); - let active_pane = *state.workspaces[0].panes.keys().next().unwrap(); - let bg_pane = *state.workspaces[1].panes.keys().next().unwrap(); - - state.selection = Some(crate::selection::Selection::anchor(active_pane, 0, 0, None)); - state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll { - direction: crate::app::state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - - state.handle_pane_died(bg_pane); - - assert!(state.selection.is_some()); - assert!(state.selection_autoscroll.is_some()); - state.assert_invariants_for_test(); - } - - #[test] - fn pane_died_same_pane_clears_selection() { - let mut state = app_with_workspaces(&["test"]); - let first_id = state.workspaces[0].tabs[0].root_pane; - let second_id = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - - state.selection = Some(crate::selection::Selection::anchor(second_id, 0, 0, None)); - state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll { - direction: crate::app::state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - - state.handle_pane_died(second_id); - - // first_id still alive, workspace stays, but selection was on the dying pane - assert!(state.selection.is_none()); - assert!(state.selection_autoscroll.is_none()); - assert_eq!(state.workspaces[0].panes.len(), 1); - assert_eq!(state.workspaces[0].panes.keys().next().unwrap(), &first_id); - state.assert_invariants_for_test(); - } - #[test] fn state_changed_updates_pane() { let mut state = app_with_workspaces(&["test"]); @@ -5801,13 +3831,21 @@ mod tests { let right = state.workspaces[0].test_split(Direction::Horizontal); state.workspaces[0].layout.focus_pane(root); state.workspaces[0].zoomed = true; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert_eq!(state.view.pane_infos.len(), 1); assert_eq!(state.view.pane_infos[0].id, root); state.navigate_pane(NavDirection::Right); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert!(state.workspaces[0].zoomed); assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); @@ -5822,7 +3860,11 @@ mod tests { let root = state.workspaces[0].tabs[0].root_pane; let right = state.workspaces[0].test_split(Direction::Horizontal); state.workspaces[0].layout.focus_pane(root); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let before_root_rect = state .view .pane_infos @@ -5839,7 +3881,11 @@ mod tests { .rect; assert!(state.swap_pane(NavDirection::Right)); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); assert_eq!( @@ -5871,10 +3917,18 @@ mod tests { let right = state.workspaces[0].test_split(Direction::Horizontal); state.workspaces[0].layout.focus_pane(root); state.workspaces[0].zoomed = true; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert!(state.swap_pane(NavDirection::Right)); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert!(state.workspaces[0].zoomed); assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); @@ -5882,7 +3936,11 @@ mod tests { assert_eq!(state.view.pane_infos[0].id, root); state.workspaces[0].zoomed = false; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_rect = state .view .pane_infos @@ -6057,8 +4115,7 @@ mod tests { let deferred = state.close_pane(); assert!(deferred); - assert_eq!(state.mode, Mode::ConfirmClose); - assert_eq!(state.selected, 0); + assert_eq!(state.selected, 1); assert_eq!(state.workspaces.len(), 2); } @@ -6071,7 +4128,6 @@ mod tests { state.close_tab(); - assert_eq!(state.request_remove_linked_worktree, None); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "selected"); } @@ -6087,8 +4143,7 @@ mod tests { let deferred = state.close_tab(); assert!(deferred); - assert_eq!(state.mode, Mode::ConfirmClose); - assert_eq!(state.selected, 0); + assert_eq!(state.selected, 1); assert_eq!(state.workspaces.len(), 2); } @@ -6101,7 +4156,6 @@ mod tests { state.close_pane(); - assert_eq!(state.request_remove_linked_worktree, None); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "selected"); } diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 29c2c317..4bc12c24 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -357,7 +357,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/agent_view.rs b/src/app/agent_view.rs index ba50ee3a..58c1d7de 100644 --- a/src/app/agent_view.rs +++ b/src/app/agent_view.rs @@ -7,7 +7,7 @@ use crate::api::schema::{ }; use crate::ui::AgentPanelEntry; -use super::{AppState, Mode}; +use super::AppState; const MAX_FILTER_DEPTH: usize = 8; const MAX_FILTER_NODES: usize = 64; @@ -78,11 +78,7 @@ pub(crate) fn apply_agent_view(app: &AppState, entries: &mut Vec Option { - if app.mode == Mode::Navigate { - app.workspaces.get(app.selected).map(|_| app.selected) - } else { - app.active - } + app.active } fn normalize_source(source: &str) -> Result { @@ -449,6 +445,10 @@ mod tests { state } + fn projected_entries(state: &AppState) -> Vec { + crate::ui::agent_panel_entries_from(state, &crate::terminal::TerminalRuntimeRegistry::new()) + } + fn current_workspace_view() -> AgentViewSetParams { AgentViewSetParams { source: "example.views".to_string(), @@ -468,16 +468,15 @@ mod tests { let mut state = state_with_agents(); state.agent_view_override = Some(current_workspace_view()); - assert_eq!(crate::ui::agent_panel_entries(&state)[0].ws_idx, 0); + assert_eq!(projected_entries(&state)[0].ws_idx, 0); - state.mode = Mode::Navigate; - state.selected = 1; - let entries = crate::ui::agent_panel_entries(&state); + state.active = Some(1); + let entries = projected_entries(&state); assert_eq!(entries.len(), 1); assert_eq!(entries[0].ws_idx, 1); - state.mode = Mode::Settings; - let entries = crate::ui::agent_panel_entries(&state); + state.active = Some(0); + let entries = projected_entries(&state); assert_eq!(entries.len(), 1); assert_eq!(entries[0].ws_idx, 0); } @@ -513,7 +512,7 @@ mod tests { }], }); - let entries = crate::ui::agent_panel_entries(&state); + let entries = projected_entries(&state); assert_eq!(entries.len(), 2); assert_eq!(entries[0].ws_idx, 1); assert_eq!(entries[1].ws_idx, 0); @@ -547,7 +546,7 @@ mod tests { sort: Vec::new(), }); - let entries = crate::ui::agent_panel_entries(&state); + let entries = projected_entries(&state); assert_eq!(entries.len(), 1); assert_eq!(entries[0].agent_kind_label.as_deref(), Some("custom-agent")); } diff --git a/src/app/agents.rs b/src/app/agents.rs index b2aac01c..f25383fe 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -80,7 +80,7 @@ impl App { self.state .focus_pane_in_workspace(resolved.ws_idx, resolved.pane_id); self.state.mark_active_tab_seen(); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; self.agent_info(resolved.ws_idx, resolved.pane_id) .ok_or_else(|| TerminalTargetError::NotFound { target: target.to_string(), diff --git a/src/app/api.rs b/src/app/api.rs index 119cc9c1..ff5a7dc4 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -28,36 +28,6 @@ enum RuntimeExitAction { } impl App { - pub(crate) fn dispatch_api_request( - &mut self, - id: &'static str, - method: crate::api::schema::Method, - ) -> String { - self.handle_api_request(crate::api::schema::Request { - id: id.to_string(), - method, - }) - } - - pub(crate) fn dispatch_deferred_api_request( - &mut self, - id: &'static str, - method: crate::api::schema::Method, - ) -> Option { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - if !self.handle_deferred_worktree_api_request( - crate::api::schema::Request { - id: id.to_string(), - method, - }, - respond_to, - ) { - return None; - } - - response_rx.try_recv().ok() - } - pub(crate) fn handle_internal_event_with_render_impact(&mut self, ev: AppEvent) -> bool { match ev { AppEvent::GitStatusRefreshed { @@ -115,9 +85,7 @@ impl App { ) -> Vec { if matches!( &ev, - AppEvent::TerminalBell { .. } - | AppEvent::ClipboardWrite { .. } - | AppEvent::PrefixInputSource { .. } + AppEvent::TerminalBell { .. } | AppEvent::ClipboardWrite { .. } ) { return Vec::new(); } @@ -173,12 +141,12 @@ impl App { } if let AppEvent::WorktreeAddFinished(result) = ev { - self.handle_worktree_add_finished(*result); + self.handle_api_worktree_add_finished(*result); return Vec::new(); } if let AppEvent::WorktreeRemoveFinished(result) = ev { - self.handle_worktree_remove_finished(*result); + self.handle_api_worktree_remove_finished(*result); return Vec::new(); } @@ -421,18 +389,6 @@ impl App { } } - pub(crate) fn show_clipboard_feedback(&mut self) { - if !self.state.toast_config.clipboard.enabled { - self.state.copy_feedback = None; - self.copy_feedback_deadline = None; - return; - } - self.state.copy_feedback = Some(crate::app::state::CopyFeedback { - message: "copied to clipboard".to_string(), - }); - self.copy_feedback_deadline = Some(Instant::now() + super::COPY_FEEDBACK_DURATION); - } - fn restore_overlay_after_exit( &mut self, overlay: OverlayPaneState, @@ -701,7 +657,7 @@ impl App { self.sync_focus_events_with_outer_event(None); } - pub(super) fn send_outer_focus_event(&mut self, event: crate::ghostty::FocusEvent) { + pub(crate) fn send_outer_focus_event(&mut self, event: crate::ghostty::FocusEvent) { self.sync_focus_events_with_outer_event(Some(event)); } @@ -780,6 +736,7 @@ impl App { runtime.try_send_focus_event(event); } + #[cfg(test)] pub(crate) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String { self.drain_all_internal_events(); self.handle_api_request_after_internal_events_drained(request) @@ -1015,6 +972,9 @@ impl App { Method::PaneGet(target) => return self.handle_pane_get(request.id, target), Method::PaneFocus(target) => return self.handle_pane_focus(request.id, target), Method::PaneInputSet(params) => return self.handle_pane_input_set(request.id, params), + Method::PaneLinkActivate(params) => { + return self.handle_pane_link_activate(request.id, params); + } Method::PaneRename(params) => return self.handle_pane_rename(request.id, params), Method::PaneRead(params) => return self.handle_pane_read(request.id, params), Method::PaneGraphicsSet(params) => { @@ -1283,7 +1243,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1310,7 +1270,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1349,7 +1309,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1395,7 +1355,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1435,7 +1395,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1476,7 +1436,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1519,7 +1479,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1564,7 +1524,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1606,7 +1566,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1640,7 +1600,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1676,7 +1636,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1769,7 +1729,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1894,7 +1854,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1931,7 +1891,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1975,7 +1935,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -2026,7 +1986,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -2112,7 +2072,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2162,7 +2122,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2196,7 +2156,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2219,7 +2179,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/agent_view.rs b/src/app/api/agent_view.rs index e14a933d..c8c48926 100644 --- a/src/app/api/agent_view.rs +++ b/src/app/api/agent_view.rs @@ -87,8 +87,6 @@ impl App { fn replace_agent_view_override(&mut self, view: Option) { self.state.agent_view_override = view; - self.state.agent_panel_scroll = 0; - self.state.mobile_switcher_scroll = 0; } } @@ -103,7 +101,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index 1ed13efc..65a65863 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -319,7 +319,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/integrations.rs b/src/app/api/integrations.rs index 32958ac8..ba1603e2 100644 --- a/src/app/api/integrations.rs +++ b/src/app/api/integrations.rs @@ -39,6 +39,7 @@ impl App { Ok(messages) => messages, Err(err) => return encode_error(id, "integration_install_failed", err.to_string()), }; + self.state.integration_recommendations = crate::integration::integration_recommendations(); encode_success( id, @@ -59,6 +60,7 @@ impl App { Ok(messages) => messages, Err(err) => return encode_error(id, "integration_uninstall_failed", err.to_string()), }; + self.state.integration_recommendations = crate::integration::integration_recommendations(); encode_success( id, diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs index 9d2756b8..943ed835 100644 --- a/src/app/api/layouts.rs +++ b/src/app/api/layouts.rs @@ -608,7 +608,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/pane_graphics.rs b/src/app/api/pane_graphics.rs index a44fc5b8..3dddccc9 100644 --- a/src/app/api/pane_graphics.rs +++ b/src/app/api/pane_graphics.rs @@ -18,17 +18,7 @@ impl App { /// workspaces/tabs and panes hidden by zoom are not placeable. Short-lived UI modes do not /// suspend the producer because the pane becomes visible again without a layout event. fn pane_graphics_visible(&self, ws_idx: usize, pane_id: PaneId) -> bool { - if self.state.active != Some(ws_idx) { - return false; - } - let Some(tab) = self.state.workspaces[ws_idx].active_tab() else { - return false; - }; - if tab.zoomed { - tab.layout.focused() == pane_id - } else { - tab.layout.pane_ids().contains(&pane_id) - } + self.state.pane_visible_on_active_surface(ws_idx, pane_id) } pub(super) fn handle_pane_graphics_info( @@ -566,7 +556,7 @@ mod tests { let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, rx, crate::api::EventHub::default(), diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 723f4af3..f1c3a667 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -114,7 +114,7 @@ impl App { self.state.switch_workspace_tab(ws_idx, target_tab_idx); self.state .record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } self.terminal_runtimes .insert(new_pane.terminal.id.clone(), new_pane.runtime); @@ -206,26 +206,31 @@ impl App { } } - pub(super) fn handle_pane_selection_read( - &mut self, - id: String, - params: PaneSelectionReadParams, - ) -> String { + pub(crate) fn pane_selection_text( + &self, + params: &PaneSelectionReadParams, + ) -> Result { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return pane_not_found(id, ¶ms.pane_id); + return Err(( + "pane_not_found", + format!("pane not found: {}", params.pane_id), + )); }; let Some(runtime) = self.state .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) else { - return pane_not_found(id, ¶ms.pane_id); + return Err(( + "pane_not_found", + format!("pane not found: {}", params.pane_id), + )); }; let before = runtime.content_seq(); if params .content_revision .is_some_and(|revision| revision != before || !before.is_multiple_of(2)) { - return encode_error(id, "stale_content", "pane content changed"); + return Err(("stale_content", "pane content changed".to_owned())); } let selection = crate::selection::Selection::absolute_range( pane_id, @@ -233,18 +238,32 @@ impl App { (params.cursor.row, params.cursor.col), ); let Some(text) = runtime.extract_selection(&selection) else { - return encode_error(id, "selection_unavailable", "selection text is unavailable"); + return Err(( + "selection_unavailable", + "selection text is unavailable".to_owned(), + )); }; if params.content_revision.is_some() && runtime.content_seq() != before { - return encode_error(id, "stale_content", "pane content changed"); + return Err(("stale_content", "pane content changed".to_owned())); + } + Ok(text) + } + + pub(super) fn handle_pane_selection_read( + &mut self, + id: String, + params: PaneSelectionReadParams, + ) -> String { + match self.pane_selection_text(¶ms) { + Ok(text) => encode_success( + id, + ResponseResult::PaneSelection { + pane_id: params.pane_id, + text, + }, + ), + Err((code, message)) => encode_error(id, code, message), } - encode_success( - id, - ResponseResult::PaneSelection { - pane_id: params.pane_id, - text, - }, - ) } pub(super) fn handle_pane_copy_motion( @@ -287,10 +306,10 @@ impl App { }; let col = match params.motion { PaneCopyMotion::LineEnd => { - crate::app::input::copy_mode::last_character_col(&text).unwrap_or(0) + crate::copy_mode::last_character_col(&text).unwrap_or(0) } PaneCopyMotion::FirstNonBlank => { - crate::app::input::copy_mode::first_non_blank_col(&text).unwrap_or(0) + crate::copy_mode::first_non_blank_col(&text).unwrap_or(0) } _ => unreachable!(), }; @@ -456,7 +475,7 @@ impl App { self.state.focus_pane_in_workspace(ws_idx, pane_id); self.state.mark_active_tab_seen(); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(pane) = self.pane_info(ws_idx, pane_id) else { return pane_not_found(id, &target.pane_id); @@ -648,7 +667,7 @@ impl App { if let Some(target_pane_id) = target { self.state.focus_pane_in_workspace(ws_idx, target_pane_id); self.state.switch_workspace_tab(ws_idx, tab_idx); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } let focused_pane_id = self .state @@ -1231,7 +1250,7 @@ impl App { .switch_workspace_tab(target_ws_idx, target_tab_idx); self.state .record_pane_focus_change(previous_focus, target_ws_idx, moved_pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } let created_workspace = created_workspace.then(|| self.workspace_info(target_ws_idx)); let created_tab = if created_tab { @@ -1388,7 +1407,7 @@ impl App { if outcome.changed || outcome.focus_changed { self.schedule_session_save(); } - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; @@ -2193,7 +2212,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2802,7 +2821,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2852,7 +2871,6 @@ mod tests { let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); - assert_eq!(app.state.request_remove_linked_worktree, None); assert!(app.state.workspaces.is_empty()); } @@ -2962,7 +2980,11 @@ mod tests { let source = app.state.workspaces[0].tabs[0].root_pane; let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(source); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let source_public = app.public_pane_id(0, source).unwrap(); let target_public = app.public_pane_id(0, target).unwrap(); @@ -2989,46 +3011,16 @@ mod tests { assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); } - #[test] - fn api_pane_swap_unfocused_source_updates_last_pane_history() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let focused = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Vertical); - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].tabs[0].layout.focus_pane(focused); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); - let source_public = app.public_pane_id(0, source).unwrap(); - let target_public = app.public_pane_id(0, target).unwrap(); - - let response = app.handle_pane_swap( - "req".into(), - PaneSwapParams { - source_pane_id: Some(source_public), - target_pane_id: Some(target_public), - ..PaneSwapParams::default() - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneSwap { swap } = success.result else { - panic!("expected pane swap response"); - }; - assert!(swap.changed); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - - app.state.last_pane(); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(focused)); - } - #[test] fn api_pane_swap_direction_no_neighbor_returns_unchanged_layout() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(source); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let source_public = app.public_pane_id(0, source).unwrap(); let response = app.handle_pane_swap( @@ -3184,124 +3176,6 @@ mod tests { Some(&source_terminal) ); } - - #[test] - fn api_pane_move_focuses_copy_mode_pane_back_into_copy_mode() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let target_tab = app.state.workspaces[0].test_add_tab(Some("target")); - let target = app.state.workspaces[0].tabs[target_tab].root_pane; - seed_terminal_states(&mut app); - app.state.copy_mode = Some(crate::app::state::CopyModeState { - pane_id: source, - cursor_row: 0, - cursor_col: 0, - entry_offset_from_bottom: 0, - selection: None, - search: Default::default(), - }); - let source_public = app.public_pane_id(0, source).unwrap(); - let target_public = app.public_pane_id(0, target).unwrap(); - let target_tab_public = app.public_tab_id(0, target_tab).unwrap(); - - let response = app.handle_pane_move( - "req".into(), - PaneMoveParams { - pane_id: source_public, - destination: PaneMoveDestination::Tab { - tab_id: target_tab_public, - target_pane_id: Some(target_public), - split: SplitDirection::Right, - ratio: None, - }, - focus: true, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneMove { move_result } = success.result else { - panic!("expected pane move response"); - }; - assert!(move_result.changed); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.copy_mode.expect("copy mode").pane_id, source); - assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), source); - } - - #[tokio::test] - async fn key_release_follows_pane_moved_across_workspaces() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let source_terminal_id = app.state.workspaces[0].tabs[0] - .terminal_id(source) - .unwrap() - .clone(); - let (runtime, mut rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[>15u", - 2, - ); - app.terminal_runtimes.insert(source_terminal_id, runtime); - app.state.workspaces.push(Workspace::test_new("other")); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let source_public = app.public_pane_id(0, source).unwrap(); - let target = app.state.workspaces[1].tabs[0].root_pane; - let target_tab_id = app.public_tab_id(1, 0).unwrap(); - let target_pane_id = app.public_pane_id(1, target).unwrap(); - - app.route_client_events_from( - 42, - vec![crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('j'), - crossterm::event::KeyModifiers::empty(), - ), - )], - false, - ); - let response = app.handle_pane_move( - "req".into(), - PaneMoveParams { - pane_id: source_public, - destination: PaneMoveDestination::Tab { - tab_id: target_tab_id, - target_pane_id: Some(target_pane_id), - split: SplitDirection::Down, - ratio: None, - }, - focus: false, - }, - ); - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - assert!(matches!(success.result, ResponseResult::PaneMove { .. })); - app.route_client_events_from( - 42, - vec![crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('j'), - crossterm::event::KeyModifiers::empty(), - ) - .with_kind(crossterm::event::KeyEventKind::Release), - )], - false, - ); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("forwarded release after pane move"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - #[test] fn api_pane_move_to_existing_tab_across_workspace_reassigns_public_pane_id() { let mut app = app_with_linked_worktree(); @@ -3862,86 +3736,6 @@ mod tests { )); } - #[test] - fn api_pane_zoom_explicit_background_pane_updates_focus_history() { - let mut app = app_with_linked_worktree(); - app.state.workspaces.push(Workspace::test_new("other")); - let first = app.state.workspaces[0].tabs[0].root_pane; - let target = app.state.workspaces[1].tabs[0].root_pane; - let _other = app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal); - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].tabs[0].layout.focus_pane(first); - let target_public = app.public_pane_id(1, target).unwrap(); - - let response = app.handle_pane_zoom( - "req".into(), - PaneZoomParams { - pane_id: Some(target_public.clone()), - mode: PaneZoomMode::On, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneZoom { zoom } = success.result else { - panic!("expected pane zoom response"); - }; - assert!(zoom.changed); - assert!(zoom.zoom_changed); - assert!(zoom.focus_changed); - assert_eq!(zoom.pane_id, target_public); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target)); - assert!(app.state.workspaces[1].tabs[0].zoomed); - - app.state.last_pane(); - - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(first)); - } - - #[test] - fn api_pane_zoom_focuses_copy_mode_pane_back_into_copy_mode() { - let mut app = app_with_linked_worktree(); - app.state.workspaces.push(Workspace::test_new("other")); - let source = app.state.workspaces[0].tabs[0].root_pane; - let target = app.state.workspaces[1].tabs[0].root_pane; - let _other = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let _target_other = - app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal); - app.state.workspaces[1].tabs[0].layout.focus_pane(target); - app.state.active = Some(1); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - app.state.copy_mode = Some(crate::app::state::CopyModeState { - pane_id: source, - cursor_row: 0, - cursor_col: 0, - entry_offset_from_bottom: 0, - selection: None, - search: Default::default(), - }); - let source_public = app.public_pane_id(0, source).unwrap(); - - let response = app.handle_pane_zoom( - "req".into(), - PaneZoomParams { - pane_id: Some(source_public), - mode: PaneZoomMode::On, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneZoom { zoom } = success.result else { - panic!("expected pane zoom response"); - }; - assert!(zoom.focus_changed); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target)); - } - #[test] fn api_pane_zoom_single_pane_returns_noop() { let mut app = app_with_linked_worktree(); @@ -4112,7 +3906,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -4143,7 +3941,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -4170,7 +3972,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let right_public = app.public_pane_id(0, right).unwrap(); let response = app.handle_pane_edges( @@ -4197,7 +4003,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(right); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -4235,7 +4045,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -4343,7 +4157,11 @@ mod tests { let mut app = app_with_linked_worktree(); let root = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let response = app.handle_pane_focus_direction( diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs index efef83b9..8aee1c31 100644 --- a/src/app/api/plugins/context.rs +++ b/src/app/api/plugins/context.rs @@ -357,10 +357,6 @@ impl App { .as_ref() .and_then(|pane| pane.cwd.clone()) .or_else(|| Some(self.default_cwd_for_workspace(ws_idx).display().to_string())); - let selected_text = focused_pane - .as_ref() - .and_then(|pane| self.parse_pane_id(&pane.pane_id)) - .and_then(|(_, pane_id)| self.selected_text_for_pane(pane_id)); PluginInvocationContext { workspace_id: Some(workspace.workspace_id), workspace_label: Some(workspace.label), @@ -372,7 +368,9 @@ impl App { focused_pane_cwd: focused_pane.as_ref().and_then(|pane| pane.cwd.clone()), focused_pane_agent: focused_pane.as_ref().and_then(|pane| pane.agent.clone()), focused_pane_status: focused_pane.as_ref().map(|pane| pane.agent_status), - selected_text, + // Selection is client presentation state. Client keybindings provide + // revision-validated coordinates; API callers can provide explicit context. + selected_text: None, invocation_source: Some("api".to_string()), correlation_id: Some(correlation_id.to_string()), clicked_url: None, @@ -380,22 +378,6 @@ impl App { } } - fn selected_text_for_pane(&self, pane_id: crate::layout::PaneId) -> Option { - let selection = self.state.selection.as_ref()?; - if selection.pane_id != pane_id || !selection.is_visible() { - return None; - } - let terminal_id = self - .state - .workspaces - .iter() - .find_map(|workspace| workspace.terminal_id(pane_id))?; - self.terminal_runtimes - .get(terminal_id) - .and_then(|runtime| runtime.extract_selection(selection)) - .filter(|text| !text.is_empty()) - } - fn default_cwd_for_workspace(&self, ws_idx: usize) -> std::path::PathBuf { self.state .workspaces diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index ddf3fba6..056315c3 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -6,11 +6,11 @@ mod runtime; use super::responses::{encode_error, encode_success}; use crate::api::schema::{ - InstalledPluginInfo, PluginActionInfo, PluginActionInvokeParams, PluginActionListParams, - PluginLinkParams, PluginListParams, PluginLogListParams, PluginManifestAction, - PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo, - PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, PluginUnlinkParams, - ResponseResult, + InstalledPluginInfo, PaneLinkActivateParams, PluginActionInfo, PluginActionInvokeParams, + PluginActionListParams, PluginLinkParams, PluginListParams, PluginLogListParams, + PluginManifestAction, PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, + PluginPaneInfo, PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, + PluginUnlinkParams, ResponseResult, }; use crate::app::App; pub(super) use manifest::normalize_plugin_id; @@ -37,7 +37,7 @@ impl App { } fn refresh_installed_plugins(&mut self) -> std::io::Result<()> { - if self.no_session { + if !self.policy.persist_plugin_registry { return Ok(()); } let entries = crate::persist::plugin_registry::try_load()?; @@ -49,7 +49,7 @@ impl App { &mut self, mutation: impl FnOnce(&mut crate::app::state::InstalledPluginRegistry) -> T, ) -> std::io::Result { - if self.no_session { + if !self.policy.persist_plugin_registry { return Ok(mutation(&mut self.state.installed_plugins)); } let (result, entries) = crate::persist::plugin_registry::update(|entries| { @@ -225,6 +225,7 @@ impl App { pub(crate) fn invoke_plugin_action_from_keybind( &mut self, action_id: String, + selected_text: Option, ) -> Result<(), String> { self.refresh_installed_plugins() .map_err(|err| format!("failed to load plugin registry: {err}"))?; @@ -241,6 +242,7 @@ impl App { .map_err(|(_, message)| message)?; let mut context = self.current_plugin_context("keybinding"); context.invocation_source = Some("keybinding".to_string()); + context.selected_text = selected_text; self.start_plugin_command( &plugin, Some(action.action_id), @@ -253,6 +255,80 @@ impl App { .map_err(|(_, message)| message) } + pub(super) fn handle_pane_link_activate( + &mut self, + id: String, + params: PaneLinkActivateParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return encode_error(id, "pane_not_found", "pane not found"); + }; + if !self.state.pane_visible_on_active_surface(ws_idx, pane_id) { + return encode_error(id, "stale_target", "pane is no longer visible"); + } + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return encode_error(id, "pane_not_found", "pane runtime not found"); + }; + let current_offset = runtime + .scroll_metrics() + .map(|metrics| metrics.offset_from_bottom as u64); + if params + .offset_from_bottom + .is_some_and(|expected| current_offset != Some(expected)) + { + return encode_error( + id, + "stale_content", + "pane viewport changed before link activation", + ); + } + let content_revision = runtime.content_seq(); + if content_revision % 2 != 0 + || params + .content_revision + .is_some_and(|expected| expected != content_revision) + { + return encode_error( + id, + "stale_content", + "pane content changed before link activation", + ); + } + let url = self.state.url_at_pane_surface_cell( + &self.terminal_runtimes, + ws_idx, + pane_id, + params.viewport_row, + params.col, + ); + if runtime.content_seq() != content_revision + || runtime + .scroll_metrics() + .map(|metrics| metrics.offset_from_bottom as u64) + != current_offset + { + return encode_error( + id, + "stale_content", + "pane content or viewport changed during link activation", + ); + } + let handled = match url.as_deref() { + Some(url) => match self.invoke_plugin_link_handler_for_url(url, pane_id) { + Ok(handled) => handled, + Err(err) => { + tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler"); + false + } + }, + None => false, + }; + encode_success(id, ResponseResult::PaneLinkActivated { url, handled }) + } + pub(crate) fn invoke_plugin_link_handler_for_url( &mut self, url: &str, @@ -392,13 +468,8 @@ impl App { "width and height are only supported when placement is popup", ); } - if placement == PluginPanePlacement::Popup && self.state.mode != crate::app::Mode::Terminal - { - return encode_error( - id, - "ui_busy", - "popup panes can only open from the normal workspace view", - ); + if placement == PluginPanePlacement::Popup && self.state.popup_pane.is_some() { + return encode_error(id, "ui_busy", "a popup pane is already open"); } match placement { PluginPanePlacement::Overlay | PluginPanePlacement::Popup => { @@ -457,7 +528,7 @@ impl App { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); } self.state.focus_pane_in_workspace(ws_idx, pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(record) = self.state.plugin_panes.get(&pane_id).cloned() else { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); }; @@ -716,7 +787,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1395,69 +1466,6 @@ platforms = ["linux", "macos"] let _ = std::fs::remove_dir_all(root); } - #[test] - fn plugin_pane_open_popup_preserves_existing_ui_modes() { - let mut app = test_app(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("modal")]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - let root_pane = app.state.workspaces[0].tabs[0].root_pane; - let root = unique_temp_path("plugin-popup-ui-busy"); - write_manifest(&root); - link_manifest(&mut app, &root); - - let open_popup = |app: &mut App, id: &str| { - app.handle_api_request(Request { - id: id.into(), - method: Method::PluginPaneOpen(PluginPaneOpenParams { - plugin_id: "example.worktree-bootstrap".into(), - entrypoint: "board".into(), - placement: Some(PluginPanePlacement::Popup), - width: None, - height: None, - workspace_id: None, - target_pane_id: None, - direction: None, - cwd: None, - focus: true, - env: std::collections::HashMap::new(), - }), - }) - }; - - app.state.mode = crate::app::Mode::Settings; - app.state.settings.original_theme = Some("settings-theme".into()); - let settings_response = open_popup(&mut app, "settings-popup"); - let settings_error: serde_json::Value = serde_json::from_str(&settings_response).unwrap(); - assert_eq!(settings_error["error"]["code"], "ui_busy"); - assert_eq!(app.state.mode, crate::app::Mode::Settings); - assert_eq!( - app.state.settings.original_theme.as_deref(), - Some("settings-theme") - ); - assert!(app.state.popup_pane.is_none()); - - let copy_mode = crate::app::state::CopyModeState { - pane_id: root_pane, - cursor_row: 2, - cursor_col: 3, - entry_offset_from_bottom: 4, - selection: None, - search: crate::app::state::CopyModeSearchState::default(), - }; - app.state.mode = crate::app::Mode::Copy; - app.state.copy_mode = Some(copy_mode.clone()); - let copy_response = open_popup(&mut app, "copy-popup"); - let copy_error: serde_json::Value = serde_json::from_str(©_response).unwrap(); - assert_eq!(copy_error["error"]["code"], "ui_busy"); - assert_eq!(app.state.mode, crate::app::Mode::Copy); - assert_eq!(app.state.copy_mode, Some(copy_mode)); - assert!(app.state.popup_pane.is_none()); - - let _ = std::fs::remove_dir_all(root); - } - #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() { @@ -1681,7 +1689,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PL let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1764,7 +1772,7 @@ command = ["sh", "-c", "sleep 1"] let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1843,7 +1851,7 @@ command = ["sh", "-c", "sleep 1"] let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1922,7 +1930,7 @@ command = ["sh", "-c", "sleep 1"] let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1958,8 +1966,8 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] write_manifest_content(&root, &manifest); link_manifest(&mut app, &root); - let open = app.handle_api_request(Request { - id: "pane-open-popup".into(), + let popup_request = |id: &str| Request { + id: id.into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.popup".into(), entrypoint: "board".into(), @@ -1973,8 +1981,14 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] focus: true, env: std::collections::HashMap::new(), }), - }); + }; + app.state.mode = crate::app::Mode::Navigate; + let open = app.handle_api_request(popup_request("pane-open-popup")); assert_eq!(response_result(&open), ResponseResult::Ok {}); + let duplicate = app.handle_api_request(popup_request("pane-open-popup-duplicate")); + let duplicate: crate::api::schema::ErrorResponse = + serde_json::from_str(&duplicate).unwrap(); + assert_eq!(duplicate.error.code, "ui_busy"); assert_eq!( read_capture_when_ready(&env_capture, || { app.drain_internal_events(); @@ -2184,7 +2198,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] .unwrap(); let mut app = test_app(); - app.no_session = false; + app.policy.persist_plugin_registry = true; let workspace = crate::workspace::Workspace::test_new("plugin-refresh"); let pane_id = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; @@ -2202,7 +2216,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] make_stale(&mut app); assert!(app - .invoke_plugin_action_from_keybind("bootstrap".into()) + .invoke_plugin_action_from_keybind("bootstrap".into(), None) .unwrap_err() .contains("disabled")); @@ -2419,7 +2433,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG } #[tokio::test] - async fn current_plugin_context_includes_selected_text_for_focused_pane() { + async fn current_plugin_context_leaves_client_owned_selection_empty() { let mut app = test_app(); let workspace = crate::workspace::Workspace::test_new("plugin-selection"); let pane_id = workspace.tabs[0].root_pane; @@ -2433,11 +2447,9 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG terminal_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"hello plugin\n"), ); - app.state.selection = Some(crate::selection::Selection::range(pane_id, 0, 0, 4, None)); - let context = app.current_plugin_context("selection-test"); - assert_eq!(context.selected_text.as_deref(), Some("hello")); + assert_eq!(context.selected_text, None); } #[cfg(unix)] diff --git a/src/app/api/session.rs b/src/app/api/session.rs index c2656af6..a7d14b3d 100644 --- a/src/app/api/session.rs +++ b/src/app/api/session.rs @@ -66,7 +66,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = crate::app::App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index 42d2b029..c1bd86e9 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -157,13 +157,6 @@ impl App { }; tab.set_custom_name(params.label.clone()); crate::logging::tab_renamed(&workspace_id, &tab_id); - if self.state.active == Some(ws_idx) { - // Reflow the tab bar so the new label width takes effect immediately. - // The tab bar renders into cached hit areas; without this refresh the - // old geometry lingers until the next refresh (e.g. a tab switch), - // leaving the visible label stale. Mirrors handle_tab_move. - self.state.refresh_tab_bar_view(); - } self.schedule_session_save(); self.emit_event(EventEnvelope { event: EventKind::TabRenamed, @@ -206,10 +199,6 @@ impl App { let tabs = self.tab_list_info(ws_idx); if moved { self.schedule_session_save(); - if self.state.active == Some(ws_idx) { - self.state.tab_scroll_follow_active = true; - self.state.refresh_tab_bar_view(); - } self.emit_event(EventEnvelope { event: EventKind::TabMoved, data: EventData::TabMoved { @@ -337,7 +326,13 @@ mod tests { fn api_tab_close_last_tab_closes_workspace_and_emits_both_events() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("tabs")]; app.state.active = Some(0); app.state.selected = 0; @@ -384,7 +379,13 @@ mod tests { fn api_tab_move_reorders_tabs_in_target_workspace() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); let mut workspace = Workspace::test_new("tabs"); workspace.test_add_tab(Some("two")); workspace.test_add_tab(Some("three")); @@ -424,42 +425,17 @@ mod tests { })); } - #[test] - fn api_tab_rename_reflows_active_tab_bar() { - let event_hub = crate::api::EventHub::default(); - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); - let workspace = Workspace::test_new("tabs"); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.view.tab_bar_rect = ratatui::layout::Rect::new(0, 0, 60, 1); - app.state.refresh_tab_bar_view(); - - let tab_id = app.public_tab_id(0, 0).unwrap(); - let width_before = app.state.view.tab_hit_areas[0].width; - - app.handle_tab_rename( - "req".into(), - TabRenameParams { - tab_id, - label: "a much longer custom tab label".into(), - }, - ); - - let width_after = app.state.view.tab_hit_areas[0].width; - assert!( - width_after > width_before, - "tab bar should reflow to the new label width immediately: \ - before={width_before}, after={width_after}" - ); - } - #[tokio::test] async fn tab_create_follows_cached_focused_pane_cwd_without_runtime() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.default_shell = exiting_test_command().into(); app.state.shell_mode = ShellModeConfig::NonLogin; let workspace = Workspace::test_new("tabs"); diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index 63d172bb..9b125e57 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -390,7 +390,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -470,7 +470,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -554,7 +554,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -710,7 +710,6 @@ mod tests { let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); - assert_eq!(app.state.request_remove_linked_worktree, None); assert_eq!(app.state.workspaces.len(), 1); assert_eq!(app.state.workspaces[0].display_name(), "parent"); } @@ -719,7 +718,13 @@ mod tests { fn api_workspace_close_event_includes_final_worktree_snapshot() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = app_with_linked_worktree().state.workspaces; let workspace_id = app.state.workspaces[0].id.clone(); @@ -753,7 +758,13 @@ mod tests { fn workspace_metadata_tokens_patch_clear_and_emit_snapshot() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one")]; let workspace_id = app.public_workspace_id(0); @@ -805,7 +816,13 @@ mod tests { fn workspace_token_ttl_expires_through_runtime_and_emits_update() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one")]; let workspace_id = app.public_workspace_id(0); let response = app.handle_workspace_report_metadata( @@ -837,7 +854,13 @@ mod tests { fn api_workspace_move_reorders_workspaces() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![ Workspace::test_new("one"), Workspace::test_new("two"), @@ -879,7 +902,13 @@ mod tests { fn api_workspace_move_block_reorders_atomically() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![ Workspace::test_new("child"), Workspace::test_new("normal"), @@ -932,7 +961,13 @@ mod tests { fn api_workspace_move_noop_does_not_emit_event() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; let moved_id = app.public_workspace_id(0); diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 4a3e23a8..cf4ca3f9 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -638,14 +638,6 @@ impl App { }); } - #[cfg(test)] - pub(crate) fn emit_worktree_opened_for_workspace(&mut self, ws_idx: usize, already_open: bool) { - let Some(worktree) = self.worktree_info_for_workspace(ws_idx) else { - return; - }; - self.emit_worktree_opened_event(ws_idx, worktree, already_open); - } - fn emit_worktree_opened_event( &mut self, ws_idx: usize, @@ -802,7 +794,13 @@ mod tests { fn test_app_with_event_hub(event_hub: crate::api::EventHub) -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - App::new(&Config::default(), true, None, api_rx, event_hub) + App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ) } #[cfg(windows)] @@ -936,6 +934,7 @@ mod tests { assert_eq!(tab.workspace_id, workspace.workspace_id); assert_eq!(root_pane.workspace_id, workspace.workspace_id); assert_eq!(worktree.branch.as_deref(), Some("worktree/api-create")); + assert!(Path::new(&worktree.path).starts_with(&worktree_root)); assert!(Path::new(&worktree.path).join("README.md").exists()); assert_eq!(app.state.workspaces.len(), 2); assert!( diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 4755b38d..110f00c9 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -377,12 +377,6 @@ impl App { self.pending_api_worktree_creates.remove(&checkout_key); if let Err(err) = result.result { - if let Some(create) = &mut self.state.worktree_create { - if create.checkout_path == result.path { - create.creating = false; - create.error = Some(err.clone()); - } - } Self::send_api_response( api.respond_to, encode_error(api.id, "worktree_create_failed", err), @@ -438,17 +432,6 @@ impl App { ws.set_custom_name(label); } } - if self - .state - .worktree_create - .as_ref() - .is_some_and(|create| create.checkout_path == result.path) - { - self.state.worktree_create = None; - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - self.state.mode = crate::app::Mode::Terminal; - } self.state.mark_session_dirty(); if created_workspace { self.emit_workspace_open_events(ws_idx); @@ -520,17 +503,6 @@ impl App { } else { "worktree_remove_failed" }; - if let Some(remove) = &mut self.state.worktree_remove { - if remove.workspace_id == result.workspace_id && remove.path == result.path { - remove.removing = false; - if code == "dirty_worktree_requires_force" && !remove.force_confirmation { - remove.force_confirmation = true; - remove.error = None; - } else { - remove.error = Some(message.clone()); - } - } - } Self::send_api_response(api.respond_to, encode_error(api.id, code, message)); return; } @@ -592,16 +564,6 @@ impl App { worktree, result.forced, ); - if self.state.worktree_remove.as_ref().is_some_and(|remove| { - remove.workspace_id == result.workspace_id && remove.path == result.path - }) { - self.state.worktree_remove = None; - self.state.mode = if self.state.active.is_some() { - crate::app::Mode::Terminal - } else { - crate::app::Mode::Navigate - }; - } let response = encode_success( api.id, ResponseResult::WorktreeRemoved { diff --git a/src/app/config_io.rs b/src/app/config_io.rs deleted file mode 100644 index e8d52dd1..00000000 --- a/src/app/config_io.rs +++ /dev/null @@ -1,68 +0,0 @@ -use super::App; - -impl App { - pub(super) fn update_config_file(&mut self, error_context: &str, update: F) -> bool - where - F: FnOnce(&str) -> String, - { - #[cfg(test)] - if std::env::var_os(crate::config::CONFIG_PATH_ENV_VAR).is_none() { - return false; - } - - if let Err(err) = crate::config::update_file(error_context, update) { - let path = crate::config::config_path(); - crate::logging::config_write_failed(&path, error_context, &err); - self.state.config_diagnostic = Some(err); - self.config_diagnostic_deadline = - Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); - return false; - } - - true - } - - pub(super) fn mark_onboarding_complete(&mut self) { - self.update_config_file("onboarding setting", |content| { - crate::config::upsert_top_level_bool(content, "onboarding", false) - }); - } - - fn save_config_edit(&mut self, edit: crate::config::ConfigEdit<'_>) { - if self.update_config_file(edit.description(), |content| edit.apply(content)) { - self.apply_config_from_disk(false); - } - } - - pub(super) fn save_theme(&mut self, name: &str) { - self.save_config_edit(crate::config::ConfigEdit::Theme(name)); - } - - pub(super) fn save_status_indicators(&mut self, style: crate::config::StatusIndicatorStyle) { - self.save_config_edit(crate::config::ConfigEdit::StatusIndicators(style)); - } - - pub(super) fn save_sound(&mut self, enabled: bool) { - self.save_config_edit(crate::config::ConfigEdit::Sound(enabled)); - } - - pub(super) fn save_toast_delivery(&mut self, delivery: crate::config::ToastDelivery) { - self.save_config_edit(crate::config::ConfigEdit::ToastDelivery(delivery)); - } - - pub(super) fn save_agent_border_labels(&mut self, enabled: bool) { - self.save_config_edit(crate::config::ConfigEdit::AgentBorderLabels(enabled)); - } - - pub(super) fn save_agent_panel_sort(&mut self, sort: crate::app::state::AgentPanelSort) { - let sort = match sort { - crate::app::state::AgentPanelSort::Spaces => { - crate::config::AgentPanelSortConfig::Spaces - } - crate::app::state::AgentPanelSort::Priority => { - crate::config::AgentPanelSortConfig::Priority - } - }; - self.save_config_edit(crate::config::ConfigEdit::AgentPanelSort(sort)); - } -} diff --git a/src/app/creation.rs b/src/app/creation.rs index 435f0a95..fbe61902 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -1,13 +1,10 @@ use std::path::PathBuf; -use crate::api::schema::{EventData, EventEnvelope, EventKind}; -#[cfg(test)] -use tracing::error; - use super::{ api_helpers::{pane_agent_status, tab_attention_priority}, App, Mode, }; +use crate::api::schema::{EventData, EventEnvelope, EventKind}; use crate::{config::NewTerminalCwdConfig, workspace::Workspace}; pub(crate) fn resolve_new_terminal_cwd( @@ -103,129 +100,6 @@ impl App { }) } - pub(super) fn begin_tui_workspace_create(&mut self, request_id: &'static str) { - if self.state.prompt_new_workspace_name { - let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - let cwd = self.resolve_new_terminal_cwd(follow_cwd); - super::input::open_new_workspace_dialog(&mut self.state, cwd); - return; - } - - self.runtime_workspace_create( - request_id, - crate::api::schema::WorkspaceCreateParams { - source_workspace_id: None, - cwd: None, - focus: true, - label: None, - env: Default::default(), - }, - ); - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - /// Create a workspace with a real PTY (needs event_tx). - #[cfg(test)] - pub(crate) fn create_workspace(&mut self) { - let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd); - if let Err(e) = self.create_workspace_with_events(initial_cwd, true) { - error!(err = %e, "failed to create workspace"); - self.state.mode = Mode::Navigate; - } - } - - #[cfg(test)] - pub(crate) fn create_tab(&mut self) { - let custom_name = self.state.requested_new_tab_name.take(); - let active_before = self.state.active; - let follow_cwd = self.state.active.and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd); - match self.create_tab_with_options(initial_cwd, true) { - Ok(created_idx) => { - let created_workspace = active_before.is_none(); - let ws_idx = if created_workspace { - Some(created_idx) - } else { - self.state.active - }; - let tab_idx = if created_workspace { 0 } else { created_idx }; - if let Some(name) = custom_name { - if let Some(ws) = - ws_idx.and_then(|ws_idx| self.state.workspaces.get_mut(ws_idx)) - { - if let Some(tab) = ws.tabs.get_mut(tab_idx) { - tab.set_custom_name(name); - } - self.schedule_session_save(); - } - } - if let Some(ws_idx) = ws_idx { - if created_workspace { - self.emit_workspace_open_events(ws_idx); - } else { - self.emit_tab_created_events(ws_idx, tab_idx); - } - } - } - Err(e) => { - error!(err = %e, "failed to create tab"); - } - } - } - - #[cfg(test)] - pub(super) fn create_tab_with_options( - &mut self, - initial_cwd: PathBuf, - focus: bool, - ) -> std::io::Result { - let Some(ws_idx) = self.state.active else { - return self.create_workspace_with_options(initial_cwd, focus); - }; - let (rows, cols) = self.state.estimate_pane_size(); - let ws = &mut self.state.workspaces[ws_idx]; - let (idx, terminal, runtime) = ws.create_tab( - rows, - cols, - initial_cwd, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.state.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), - Vec::new(), - )?; - let root_pane = ws.tabs[idx].root_pane; - self.terminal_runtimes.insert(terminal.id.clone(), runtime); - self.state.terminals.insert(terminal.id.clone(), terminal); - self.state.remove_alias_shadowed_by_new_pane(root_pane); - if focus { - self.state.switch_workspace_tab(ws_idx, idx); - self.state.mode = Mode::Terminal; - } - let workspace_id = self.state.workspaces[ws_idx].id.clone(); - let tab_id = self - .public_tab_id(ws_idx, idx) - .unwrap_or_else(|| crate::workspace::public_tab_id_for_number(&workspace_id, idx + 1)); - let root_pane = self.state.workspaces[ws_idx].tabs[idx].root_pane.raw(); - crate::logging::tab_created(&workspace_id, &tab_id, root_pane); - self.schedule_session_save(); - Ok(idx) - } - pub(crate) fn create_workspace_with_options( &mut self, initial_cwd: PathBuf, @@ -234,17 +108,6 @@ impl App { self.create_workspace_with_launch_env(initial_cwd, focus, Vec::new()) } - #[cfg(test)] - pub(crate) fn create_workspace_with_events( - &mut self, - initial_cwd: PathBuf, - focus: bool, - ) -> std::io::Result<()> { - let ws_idx = self.create_workspace_with_options(initial_cwd, focus)?; - self.emit_workspace_open_events(ws_idx); - Ok(()) - } - pub(crate) fn create_workspace_with_launch_env( &mut self, initial_cwd: PathBuf, diff --git a/src/app/custom_commands.rs b/src/app/custom_commands.rs index dcd9a85f..a2245884 100644 --- a/src/app/custom_commands.rs +++ b/src/app/custom_commands.rs @@ -1,7 +1,12 @@ +use std::fs; +use std::io::{self, Write}; +use std::process::Stdio; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; -use super::App; +use ratatui::layout::Direction; + +use super::{App, Mode}; static NEXT_COMMAND_NAMESPACE: AtomicU64 = AtomicU64::new(1); @@ -87,7 +92,36 @@ impl App { if let Err((code, message)) = self.focus_client_shell_command_target(¶ms) { return crate::app::api::responses::encode_error(id, code, message); } - match self.execute_custom_command_binding(&binding) { + let selected_text = if binding.action == crate::config::CustomCommandAction::PluginAction { + let Some(selection) = params.selection.as_ref() else { + return self.execute_custom_command_response(id, &binding, None); + }; + if params.pane_id.as_deref() != Some(selection.pane_id.as_str()) { + return crate::app::api::responses::encode_error( + id, + "command_target_mismatch", + "command selection does not belong to the requested pane", + ); + } + match self.pane_selection_text(selection) { + Ok(text) => Some(text), + Err((code, message)) => { + return crate::app::api::responses::encode_error(id, code, message); + } + } + } else { + None + }; + self.execute_custom_command_response(id, &binding, selected_text) + } + + fn execute_custom_command_response( + &mut self, + id: String, + binding: &crate::config::CustomCommandKeybind, + selected_text: Option, + ) -> String { + match self.execute_custom_command_binding(binding, selected_text) { Ok(()) => crate::app::api::responses::encode_success( id, crate::api::schema::ResponseResult::Ok {}, @@ -175,6 +209,363 @@ impl App { } Ok(()) } + pub(crate) fn execute_custom_command_binding( + &mut self, + binding: &crate::config::CustomCommandKeybind, + selected_text: Option, + ) -> io::Result<()> { + match binding.action { + crate::config::CustomCommandAction::Shell => self.spawn_custom_command(binding), + crate::config::CustomCommandAction::Pane => { + self.spawn_pane_command(&binding.command, Vec::new()) + } + crate::config::CustomCommandAction::Popup => self.spawn_custom_popup_command(binding), + crate::config::CustomCommandAction::PluginAction => self + .invoke_plugin_action_from_keybind(binding.command.clone(), selected_text) + .map_err(io::Error::other), + } + } + + fn spawn_custom_popup_command( + &mut self, + binding: &crate::config::CustomCommandKeybind, + ) -> io::Result<()> { + self.spawn_popup_shell_command( + &binding.command, + None, + self.custom_command_env().0, + crate::app::popup::PopupGeometry { + width: binding.width, + height: binding.height, + }, + ) + } + + pub(crate) fn custom_command_env(&self) -> (Vec<(String, String)>, Option) { + let mut env = vec![( + crate::api::SOCKET_PATH_ENV_VAR.to_string(), + crate::api::socket_path().display().to_string(), + )]; + if let Ok(current_exe) = std::env::current_exe() { + env.push(( + "HERDR_BIN_PATH".to_string(), + current_exe.display().to_string(), + )); + } + + let mut cwd = None; + if let Some(ws_idx) = self.state.active { + env.push(( + "HERDR_ACTIVE_WORKSPACE_ID".to_string(), + self.public_workspace_id(ws_idx), + )); + if let Some(workspace) = self.state.workspaces.get(ws_idx) { + let tab_idx = workspace.active_tab_index(); + if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { + env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id)); + } + if let Some(pane_id) = workspace.focused_pane_id() { + if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { + env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); + } + if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| { + tab.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) + }) { + env.push(( + "HERDR_ACTIVE_PANE_CWD".to_string(), + pane_cwd.display().to_string(), + )); + if pane_cwd.is_dir() { + cwd = Some(pane_cwd); + } + } + } + } + } + (env, cwd) + } + + fn spawn_custom_command( + &mut self, + binding: &crate::config::CustomCommandKeybind, + ) -> std::io::Result<()> { + let mut command = crate::platform::detached_custom_command_process(&binding.command); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let (env, cwd) = self.custom_command_env(); + command.envs(env); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + let child = command.spawn()?; + self.detached_process_children.push(child); + Ok(()) + } + + pub(crate) fn open_focused_scrollback_in_editor(&mut self) -> std::io::Result<()> { + let ws_idx = self + .state + .active + .ok_or_else(|| std::io::Error::other("no active workspace"))?; + let ws = self + .state + .workspaces + .get(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let pane_id = ws + .focused_pane_id() + .ok_or_else(|| std::io::Error::other("no focused pane"))?; + let scrollback = self + .state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))? + .recent_unwrapped_text_snapshot(usize::MAX) + .text; + + let path = write_scrollback_temp_file(&scrollback)?; + + let argv = match crate::platform::scrollback_editor_argv(&path) { + Ok(argv) => argv, + Err(err) => { + let _ = fs::remove_file(&path); + return Err(err); + } + }; + let (env, _) = self.custom_command_env(); + let new_pane = match self.spawn_overlay_argv_command(&argv, None, env, vec![path.clone()]) { + Ok((_, new_pane)) => new_pane, + Err(err) => { + let _ = fs::remove_file(&path); + return Err(err); + } + }; + let terminal_id = new_pane.terminal.id.clone(); + self.terminal_runtimes + .insert(terminal_id.clone(), new_pane.runtime); + self.state + .remove_alias_shadowed_by_new_pane(new_pane.pane_id); + self.state.terminals.insert(terminal_id, new_pane.terminal); + + if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::Finished, + title: "opened scrollback".to_string(), + context: format!("focused pane {public_pane_id}"), + position: None, + target: None, + }); + } + Ok(()) + } + + fn spawn_pane_command( + &mut self, + command: &str, + temp_files: Vec, + ) -> std::io::Result<()> { + let Some(ws_idx) = self.state.active else { + return Err(std::io::Error::other("no active workspace")); + }; + let previous_focus_target = self.state.current_pane_focus_target(); + let (rows, cols) = self.state.estimate_pane_size(); + let new_rows = rows.max(4); + let new_cols = cols.max(10); + let (env, _) = self.custom_command_env(); + + let ws = self + .state + .workspaces + .get_mut(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let tab_idx = ws.active_tab_index(); + let previous_focus = ws + .focused_pane_id() + .ok_or_else(|| std::io::Error::other("no focused pane"))?; + let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); + let cwd = ws.active_tab().and_then(|tab| { + tab.cwd_for_pane( + previous_focus, + &self.state.terminals, + &self.terminal_runtimes, + ) + }); + let new_pane = ws.split_focused_command( + Direction::Horizontal, + new_rows, + new_cols, + cwd, + command, + env, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + self.state.host_terminal_appearance, + )?; + let new_pane_id = new_pane.pane_id; + self.terminal_runtimes + .insert(new_pane.terminal.id.clone(), new_pane.runtime); + self.state + .terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); + let new_focus_target = crate::app::state::PaneFocusTarget { + workspace_id: ws.id.clone(), + pane_id: new_pane_id, + }; + if previous_focus_target.as_ref() != Some(&new_focus_target) { + self.state.previous_pane_focus = previous_focus_target; + } + ws.active_tab_mut() + .expect("workspace must have an active tab") + .layout + .focus_pane(new_pane_id); + ws.active_tab_mut() + .expect("workspace must have an active tab") + .zoomed = true; + self.overlay_panes.insert( + new_pane_id, + super::OverlayPaneState { + ws_idx, + tab_idx, + previous_focus, + previous_zoomed, + temp_files, + }, + ); + self.state.remove_alias_shadowed_by_new_pane(new_pane_id); + self.state.mode = Mode::Terminal; + Ok(()) + } + + pub(crate) fn spawn_overlay_argv_command( + &mut self, + argv: &[String], + cwd: Option, + extra_env: Vec<(String, String)>, + temp_files: Vec, + ) -> std::io::Result<(usize, crate::workspace::NewPane)> { + let Some(ws_idx) = self.state.active else { + return Err(std::io::Error::other("no active workspace")); + }; + let previous_focus_target = self.state.current_pane_focus_target(); + let (rows, cols) = self.state.estimate_pane_size(); + let new_rows = rows.max(4); + let new_cols = cols.max(10); + + let ws = self + .state + .workspaces + .get(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let previous_focus = ws + .focused_pane_id() + .ok_or_else(|| std::io::Error::other("no focused pane"))?; + let cwd = cwd.or_else(|| { + ws.active_tab().and_then(|tab| { + tab.cwd_for_pane( + previous_focus, + &self.state.terminals, + &self.terminal_runtimes, + ) + }) + }); + + let (tab_idx, new_pane, workspace_id) = { + let ws = self + .state + .workspaces + .get_mut(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); + let result = ws.split_pane_argv_command( + previous_focus, + Direction::Horizontal, + new_rows, + new_cols, + cwd, + argv, + extra_env, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + self.state.host_terminal_appearance, + true, + ); + let (tab_idx, new_pane) = match result { + Some(Ok(result)) => result, + Some(Err(err)) => return Err(err), + None => return Err(std::io::Error::other("focused pane disappeared")), + }; + ws.tabs + .get_mut(tab_idx) + .ok_or_else(|| std::io::Error::other("plugin overlay tab disappeared"))? + .zoomed = true; + self.overlay_panes.insert( + new_pane.pane_id, + super::OverlayPaneState { + ws_idx, + tab_idx, + previous_focus, + previous_zoomed, + temp_files, + }, + ); + (tab_idx, new_pane, ws.id.clone()) + }; + + let new_focus_target = crate::app::state::PaneFocusTarget { + workspace_id, + pane_id: new_pane.pane_id, + }; + if previous_focus_target.as_ref() != Some(&new_focus_target) { + self.state.previous_pane_focus = previous_focus_target; + } + self.state.switch_workspace_tab(ws_idx, tab_idx); + self.state.mode = Mode::Terminal; + Ok((ws_idx, new_pane)) + } +} + +fn write_scrollback_temp_file(content: &str) -> io::Result { + let mut last_collision = None; + for attempt in 0..16 { + let path = unique_scrollback_path(attempt); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + match options.open(&path) { + Ok(mut file) => { + file.write_all(content.as_bytes())?; + return Ok(path); + } + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { + last_collision = Some(err); + } + Err(err) => return Err(err), + } + } + + Err(last_collision.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::AlreadyExists, + "failed to create unique scrollback temp file", + ) + })) +} + +fn unique_scrollback_path(attempt: u32) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "herdr-scrollback-{}-{nanos}-{attempt}.txt", + std::process::id() + )) } #[cfg(test)] @@ -183,7 +574,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); crate::app::App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -239,6 +630,7 @@ mod tests { workspace_id: None, tab_id: None, pane_id: None, + selection: None, }, ); let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); @@ -287,12 +679,55 @@ mod tests { workspace_id: None, tab_id: None, pane_id: None, + selection: None, }, ); let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(error.error.code, "command_not_found"); } + #[tokio::test] + async fn plugin_command_rejects_stale_client_selection_before_invocation() { + let mut app = test_app(); + let workspace = crate::workspace::Workspace::test_new("plugin-selection"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.terminal_runtimes.insert( + terminal_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"selected text\n"), + ); + let mut plugin = binding(crate::config::CustomCommandAction::PluginAction); + plugin.command = "missing.plugin-action".into(); + install(&mut app, plugin); + let command_id = app.client_shell_command_manifest()[0].command_id.clone(); + let workspace_id = app.public_workspace_id(0); + let tab_id = app.public_tab_id(0, 0).unwrap(); + let pane_id = app.public_pane_id(0, pane_id).unwrap(); + + let response = app.handle_command_invoke( + "request-selection".into(), + crate::api::schema::CommandInvokeParams { + command_id, + workspace_id: Some(workspace_id), + tab_id: Some(tab_id), + pane_id: Some(pane_id.clone()), + selection: Some(crate::api::schema::PaneSelectionReadParams { + pane_id, + anchor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 7 }, + content_revision: Some(u64::MAX), + }), + }, + ); + + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "stale_content"); + } + #[cfg(unix)] #[test] fn shell_command_invocation_executes_endpoint_owned_definition() { @@ -318,6 +753,7 @@ mod tests { workspace_id: None, tab_id: None, pane_id: None, + selection: None, }, ); let success: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); diff --git a/src/app/git_refresh.rs b/src/app/git_refresh.rs index c266add4..fd512d55 100644 --- a/src/app/git_refresh.rs +++ b/src/app/git_refresh.rs @@ -529,7 +529,7 @@ mod tests { fn test_app(config: &crate::config::Config) -> super::super::App { super::super::App::new( config, - true, + crate::app::AppPolicy::TEST, None, tokio::sync::mpsc::unbounded_channel().1, crate::api::EventHub::default(), diff --git a/src/app/input/clipboard.rs b/src/app/input/clipboard.rs deleted file mode 100644 index d2aa7ba5..00000000 --- a/src/app/input/clipboard.rs +++ /dev/null @@ -1,371 +0,0 @@ -use crossterm::event::{KeyCode, KeyModifiers}; - -use crate::{ - app::{App, InputSourceId}, - input::TerminalKey, -}; - -use super::{ConsumedInputLease, InputLeaseKey}; - -fn is_retained_selection_copy_key(key: &TerminalKey) -> bool { - matches!(key.code, KeyCode::Char('c' | 'C')) - && matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER) -} - -impl App { - pub(super) fn dispatch_pending_clipboard_write(&mut self) -> bool { - let Some(content) = self.state.request_clipboard_write.take() else { - return false; - }; - if self - .event_tx - .try_send(crate::events::AppEvent::ClipboardWrite { content }) - .is_err() - { - tracing::warn!("failed to queue clipboard write event"); - } - true - } - - pub(super) fn try_copy_retained_selection( - &mut self, - source_id: InputSourceId, - key: TerminalKey, - ) -> bool { - if self.state.copy_on_select - || !is_retained_selection_copy_key(&key) - || !self - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_visible) - { - return false; - } - - self.state.copy_selection(&self.terminal_runtimes); - self.selection_autoscroll_deadline = None; - if !self.dispatch_pending_clipboard_write() { - return false; - } - - self.input_leases.insert_consumed( - InputLeaseKey::new(source_id, &key), - ConsumedInputLease::SuppressRepeats, - ); - true - } -} - -#[cfg(test)] -mod tests { - use bytes::Bytes; - use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; - use ratatui::layout::Rect; - - use super::super::{app_for_mouse_test, mouse}; - use super::*; - use crate::{app::Mode, events::AppEvent, workspace::Workspace}; - - fn app_with_screen_bytes_and_input( - bytes: &[u8], - ) -> ( - App, - crate::layout::PaneInfo, - tokio::sync::mpsc::Receiver, - ) { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - bytes, - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - (app, info, input_rx) - } - - fn drag_select_range( - app: &mut App, - info: &crate::layout::PaneInfo, - start_col: u16, - end_col: u16, - ) { - let row = info.inner_rect.y; - let start_col = info.inner_rect.x + start_col; - let end_col = info.inner_rect.x + end_col; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - } - - fn clipboard_write_content(app: &mut App) -> Vec { - match app.event_rx.try_recv().expect("clipboard write event") { - AppEvent::ClipboardWrite { content } => content, - event => panic!("unexpected event: {event:?}"), - } - } - - fn assert_visible_selection(app: &App) { - assert!(app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_visible)); - } - - #[tokio::test] - async fn copy_on_select_disabled_ctrl_c_copies_and_clears_retained_selection() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - app.state.copy_on_select = false; - drag_select_range(&mut app, &info, 0, 4); - assert_visible_selection(&app); - assert!(app.event_rx.try_recv().is_err()); - - let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL) - .with_windows_record(crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 0x43, - virtual_scan_code: 0x2e, - unicode: 'c' as u16, - control_key_state: 0x0008, - }); - let source_id = 41; - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())], - false, - ); - - let content = clipboard_write_content(&mut app); - assert_eq!(content, b"alpha"); - assert!(app.state.selection.is_none()); - assert!(input_rx.try_recv().is_err()); - - let _ = content; - app.show_clipboard_feedback(); - assert_eq!( - app.state - .copy_feedback - .as_ref() - .map(|feedback| feedback.message.as_str()), - Some("copied to clipboard") - ); - - app.route_client_events_from( - source_id, - vec![ - crate::raw_input::RawInputEvent::Key(ctrl_c.clone()), - crate::raw_input::RawInputEvent::Key( - ctrl_c.clone().with_kind(KeyEventKind::Repeat), - ), - ], - false, - ); - assert_eq!(app.input_leases.len(), 1); - assert!(app.event_rx.try_recv().is_err()); - assert!(input_rx.try_recv().is_err()); - - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key( - ctrl_c.clone().with_kind(KeyEventKind::Release), - )], - false, - ); - assert!(app.input_leases.is_empty()); - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())], - false, - ); - let expected = if cfg!(windows) { - b"\x1b[67;46;99;1;8;1_".as_slice() - } else { - b"\x03".as_slice() - }; - assert_eq!( - input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), - expected - ); - assert!(app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn copy_on_select_disabled_cmd_c_copies_retained_selection() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - app.state.copy_on_select = false; - drag_select_range(&mut app, &info, 0, 4); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Char('c'), KeyModifiers::SUPER)); - - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - assert!(app.state.selection.is_none()); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn copy_shortcut_before_delayed_mouse_up_copies_in_progress_selection() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - app.state.copy_on_select = false; - let source_id = 41; - let row = info.inner_rect.y; - let start_col = info.inner_rect.x; - let end_col = info.inner_rect.x + 4; - app.route_client_events_from( - source_id, - vec![ - crate::raw_input::RawInputEvent::Mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )), - crate::raw_input::RawInputEvent::Mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - end_col, - row, - )), - ], - false, - ); - assert_visible_selection(&app); - assert!(app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_in_progress)); - assert!(app.state.selection_autoscroll.is_some()); - assert!(app.selection_autoscroll_deadline.is_some()); - - let cmd_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::SUPER); - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key(cmd_c.clone())], - false, - ); - - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - assert!(app.event_rx.try_recv().is_err()); - assert!(app.state.selection.is_none()); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert_eq!(app.input_leases.len(), 1); - assert!(input_rx.try_recv().is_err()); - - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key( - cmd_c.with_kind(KeyEventKind::Release), - )], - false, - ); - assert!(app.input_leases.is_empty()); - - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - end_col, - row, - ))], - false, - ); - - assert!(app.state.selection.is_none()); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert!(app.input_leases.is_empty()); - assert!(app.event_rx.try_recv().is_err()); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn retained_selection_copy_shortcut_is_disabled_with_copy_on_select() { - let (mut app, _info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let mut selection = crate::selection::Selection::range( - pane_id, - 0, - 0, - 4, - app.state - .pane_scroll_metrics(&app.terminal_runtimes, pane_id), - ); - assert!(selection.finish()); - app.state.selection = Some(selection); - app.state.copy_on_select = true; - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - )); - - assert!(app.state.selection.is_none()); - assert!(app.event_rx.try_recv().is_err()); - assert_eq!( - input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), - b"\x03" - ); - } - - #[tokio::test] - async fn retained_selection_copy_shortcut_forwards_when_selection_text_is_empty() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b""); - app.state.copy_on_select = false; - drag_select_range(&mut app, &info, 0, 4); - assert_visible_selection(&app); - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - )); - - assert!(app.state.selection.is_none()); - assert!(app.event_rx.try_recv().is_err()); - assert_eq!( - input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), - b"\x03" - ); - } - - #[tokio::test] - async fn retained_selection_copy_shortcut_requires_exact_modifiers() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - app.state.copy_on_select = false; - drag_select_range(&mut app, &info, 0, 4); - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::Char('C'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - )); - - assert!(app.state.selection.is_none()); - assert!(app.event_rx.try_recv().is_err()); - assert_eq!( - input_rx - .try_recv() - .expect("forwarded Ctrl-Shift-C") - .as_ref(), - b"\x03" - ); - } -} diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs deleted file mode 100644 index 185ce2e3..00000000 --- a/src/app/input/copy_mode.rs +++ /dev/null @@ -1,2153 +0,0 @@ -use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; - -use crate::{ - app::{ - state::{CopyModeSearchDirection, CopyModeSearchPrompt, CopyModeSelection, CopyModeState}, - App, AppState, Mode, - }, - input::TerminalKey, - selection::Selection, - terminal::TerminalRuntimeRegistry, -}; - -impl App { - pub(crate) fn handle_copy_mode_key(&mut self, key: TerminalKey) { - if key.kind == KeyEventKind::Release { - return; - } - self.state.update_dismissed = true; - if self.state.is_prefix_key(&key) { - self.state.mode = Mode::Prefix; - return; - } - self.state - .handle_copy_mode_key(&self.terminal_runtimes, key); - self.dispatch_pending_clipboard_write(); - } -} - -impl AppState { - pub(crate) fn enter_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(ws_idx) = self.active else { - return; - }; - let Some(pane_id) = self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.focused_pane_id()) - else { - return; - }; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - if info.inner_rect.width == 0 || info.inner_rect.height == 0 { - return; - } - - let cursor = self - .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - .and_then(|rt| rt.cursor_state(info.inner_rect, true)) - .filter(|cursor| cursor.visible) - .map(|cursor| { - ( - cursor.y.saturating_sub(info.inner_rect.y), - cursor.x.saturating_sub(info.inner_rect.x), - ) - }) - .unwrap_or_else(|| (info.inner_rect.height.saturating_sub(1), 0)); - let entry_offset_from_bottom = self - .pane_scroll_metrics(terminal_runtimes, pane_id) - .map_or(0, |metrics| metrics.offset_from_bottom); - - self.clear_selection(); - self.copy_mode = Some(CopyModeState { - pane_id, - cursor_row: cursor.0.min(info.inner_rect.height.saturating_sub(1)), - cursor_col: cursor.1.min(info.inner_rect.width.saturating_sub(1)), - entry_offset_from_bottom, - selection: None, - search: crate::app::state::CopyModeSearchState { - geometry: Some((info.inner_rect.width, info.inner_rect.height)), - ..Default::default() - }, - }); - self.mode = Mode::Copy; - } - - pub(crate) fn handle_copy_mode_key( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - key: TerminalKey, - ) { - if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key.clone()) { - 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 { - self.clear_copy_mode_selection(); - if let Some(search) = self - .copy_mode - .as_mut() - .map(|copy_mode| &mut copy_mode.search) - { - let geometry = search.geometry; - *search = crate::app::state::CopyModeSearchState { - geometry, - ..Default::default() - }; - } - return; - } - self.exit_copy_mode(terminal_runtimes, false); - return; - } - KeyCode::Enter => { - self.exit_copy_mode(terminal_runtimes, true); - return; - } - KeyCode::Left => { - self.move_copy_cursor(terminal_runtimes, 0, -1); - return; - } - KeyCode::Down => { - self.move_copy_cursor(terminal_runtimes, 1, 0); - return; - } - KeyCode::Up => { - self.move_copy_cursor(terminal_runtimes, -1, 0); - return; - } - KeyCode::Right => { - self.move_copy_cursor(terminal_runtimes, 0, 1); - return; - } - KeyCode::PageUp => { - self.scroll_copy_mode_page(terminal_runtimes, -1, false); - return; - } - KeyCode::PageDown => { - self.scroll_copy_mode_page(terminal_runtimes, 1, false); - return; - } - KeyCode::Home => { - self.copy_mode_line_edge(terminal_runtimes, false); - return; - } - KeyCode::End => { - self.copy_mode_line_edge(terminal_runtimes, true); - return; - } - _ => {} - } - - match (key.code, key.modifiers) { - (KeyCode::Char('b'), mods) if mods.contains(KeyModifiers::CONTROL) => { - self.scroll_copy_mode_page(terminal_runtimes, -1, false) - } - (KeyCode::Char('f'), mods) if mods.contains(KeyModifiers::CONTROL) => { - self.scroll_copy_mode_page(terminal_runtimes, 1, false) - } - (KeyCode::Char('u'), mods) if mods.contains(KeyModifiers::CONTROL) => { - self.scroll_copy_mode_page(terminal_runtimes, -1, true) - } - (KeyCode::Char('d'), mods) if mods.contains(KeyModifiers::CONTROL) => { - self.scroll_copy_mode_page(terminal_runtimes, 1, true) - } - _ => {} - } - - let Some(ch) = copy_mode_command_char(key) else { - return; - }; - match ch { - 'q' => self.exit_copy_mode(terminal_runtimes, false), - 'y' => self.exit_copy_mode(terminal_runtimes, true), - 'v' | ' ' => self.begin_copy_mode_selection(terminal_runtimes), - 'V' => self.select_copy_mode_line(terminal_runtimes), - 'h' => self.move_copy_cursor(terminal_runtimes, 0, -1), - 'j' => self.move_copy_cursor(terminal_runtimes, 1, 0), - 'k' => self.move_copy_cursor(terminal_runtimes, -1, 0), - 'l' => self.move_copy_cursor(terminal_runtimes, 0, 1), - 'g' => self.copy_mode_history_top(terminal_runtimes), - 'G' => self.copy_mode_history_bottom(terminal_runtimes), - '0' => self.copy_mode_line_edge(terminal_runtimes, false), - '$' => self.copy_mode_line_edge(terminal_runtimes, true), - '^' => self.copy_mode_first_non_blank(terminal_runtimes), - '/' => self.open_copy_mode_search(CopyModeSearchDirection::Forward), - '?' => self.open_copy_mode_search(CopyModeSearchDirection::Backward), - 'n' => self.repeat_copy_mode_search(terminal_runtimes, false), - 'N' => self.repeat_copy_mode_search(terminal_runtimes, true), - 'w' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextStart), - 'b' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::PreviousStart), - 'e' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextEnd), - 'W' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextBigStart), - 'B' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::PreviousBigStart), - 'E' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextBigEnd), - '{' => self.copy_mode_paragraph(terminal_runtimes, -1), - '}' => self.copy_mode_paragraph(terminal_runtimes, 1), - _ => {} - } - } - - fn handle_copy_mode_search_prompt_key( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - key: TerminalKey, - ) -> bool { - let Some(copy_mode) = self.copy_mode.as_mut() else { - return false; - }; - let Some(prompt) = copy_mode.search.prompt.as_mut() else { - return false; - }; - match key.code { - KeyCode::Esc => { - copy_mode.search.prompt = None; - } - KeyCode::Enter => { - let direction = prompt.direction; - let query = std::mem::take(&mut prompt.query); - copy_mode.search.prompt = None; - self.submit_copy_mode_search(terminal_runtimes, query, direction, false); - } - KeyCode::Backspace => { - prompt.query.pop(); - } - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - prompt.query.clear(); - } - _ => { - if let Some(ch) = copy_mode_command_char(key) { - prompt.query.push(ch); - } - } - } - true - } - - fn open_copy_mode_search(&mut self, direction: CopyModeSearchDirection) { - let Some(copy_mode) = self.copy_mode.as_mut() else { - return; - }; - copy_mode.search.prompt = Some(CopyModeSearchPrompt { - direction, - query: String::new(), - }); - } - - fn repeat_copy_mode_search( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - reverse: bool, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - if copy_mode.search.query.is_empty() { - return; - } - let Some(mut direction) = copy_mode.search.direction else { - return; - }; - if reverse { - direction = direction.reversed(); - } - self.submit_copy_mode_search( - terminal_runtimes, - copy_mode.search.query.clone(), - direction, - true, - ); - } - - fn submit_copy_mode_search( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - query: String, - direction: CopyModeSearchDirection, - repeat: bool, - ) { - if query.is_empty() { - return; - } - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(ws_idx) = self.active else { - return; - }; - let Some(runtime) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - else { - return; - }; - let Some(metrics) = runtime.scroll_metrics() else { - return; - }; - let cursor = crate::pane::TerminalTextPoint { - row: viewport_top_row(metrics).saturating_add(u32::from(copy_mode.cursor_row)), - col: copy_mode.cursor_col, - }; - let previous_match = repeat - .then(|| { - copy_mode - .search - .current - .and_then(|index| copy_mode.search.matches.get(index).copied()) - }) - .flatten() - .filter(|text_match| { - text_match.start == cursor && runtime.text_match_is_current(*text_match) - }); - let matches = runtime.search_text_matches(&query, query.chars().any(char::is_uppercase)); - let current = search_match_index(&matches, direction, cursor, previous_match); - - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.search.query = query; - if !repeat { - copy_mode.search.direction = Some(direction); - } - copy_mode.search.matches = matches; - copy_mode.search.current = current; - } - - let Some(target) = current.and_then(|index| { - self.copy_mode - .as_ref() - .and_then(|copy_mode| copy_mode.search.matches.get(index).copied()) - }) else { - return; - }; - self.move_copy_cursor_to_absolute(terminal_runtimes, target.start, true); - } - - fn move_copy_cursor_to_absolute( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - target: crate::pane::TerminalTextPoint, - reserve_overlay_row: bool, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) else { - return; - }; - let current_top = viewport_top_row(metrics); - let max_cursor_row = info - .inner_rect - .height - .saturating_sub(if reserve_overlay_row { 2 } else { 1 }); - let desired_top = if target.row < current_top { - target.row - } else if target.row > current_top.saturating_add(u32::from(max_cursor_row)) { - target.row.saturating_sub(u32::from(max_cursor_row)) - } else { - current_top - }; - let desired_offset = metrics - .max_offset_from_bottom - .saturating_sub(desired_top as usize); - self.set_pane_scroll_offset(terminal_runtimes, pane_id, desired_offset); - let Some(updated_metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) else { - return; - }; - let updated_top = viewport_top_row(updated_metrics); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = target - .row - .saturating_sub(updated_top) - .min(u32::from(info.inner_rect.height.saturating_sub(1))) - as u16; - copy_mode.cursor_col = target.col.min(info.inner_rect.width.saturating_sub(1)); - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - pub(crate) fn cancel_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - self.exit_copy_mode(terminal_runtimes, false); - } - - fn exit_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, copy: bool) { - let restore_scroll = self - .copy_mode - .as_ref() - .map(|copy_mode| (copy_mode.pane_id, copy_mode.entry_offset_from_bottom)); - if copy { - self.copy_selection(terminal_runtimes); - } else { - self.clear_selection(); - } - if let Some((pane_id, offset_from_bottom)) = restore_scroll { - self.set_pane_scroll_offset(terminal_runtimes, pane_id, offset_from_bottom); - } - self.copy_mode = None; - self.mode = if self.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - fn begin_copy_mode_selection(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id).cloned() else { - return; - }; - if copy_mode.cursor_row >= info.inner_rect.height - || copy_mode.cursor_col >= info.inner_rect.width - { - return; - } - - let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id); - self.selection = Some(Selection::anchor( - copy_mode.pane_id, - copy_mode.cursor_row, - copy_mode.cursor_col, - metrics, - )); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.selection = Some(CopyModeSelection::Character); - } - } - - fn select_copy_mode_line(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let cursor_row = copy_mode.cursor_row; - let Some(info) = self.pane_info_by_id(pane_id) else { - return; - }; - let end_col = info.inner_rect.width.saturating_sub(1); - let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - let anchor_row = crate::selection::absolute_row_for_viewport(cursor_row, metrics); - self.selection = Some(Selection::line_range( - pane_id, anchor_row, anchor_row, end_col, - )); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.selection = Some(CopyModeSelection::Linewise { anchor_row }); - } - } - - fn move_copy_cursor( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - row_delta: i16, - col_delta: i16, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let mut cursor_row = copy_mode.cursor_row; - let mut cursor_col = copy_mode.cursor_col; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - - if col_delta < 0 { - cursor_col = cursor_col.saturating_sub(col_delta.unsigned_abs()); - } else if col_delta > 0 { - cursor_col = cursor_col - .saturating_add(col_delta as u16) - .min(info.inner_rect.width.saturating_sub(1)); - } - - if row_delta < 0 { - let delta = row_delta.unsigned_abs(); - if cursor_row >= delta { - cursor_row -= delta; - } else { - self.scroll_pane_up(terminal_runtimes, pane_id, usize::from(delta)); - cursor_row = 0; - } - } else if row_delta > 0 { - let delta = row_delta as u16; - let bottom = info.inner_rect.height.saturating_sub(1); - if cursor_row.saturating_add(delta) <= bottom { - cursor_row += delta; - } else { - self.scroll_pane_down(terminal_runtimes, pane_id, usize::from(delta)); - cursor_row = bottom; - } - } - - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = cursor_row; - copy_mode.cursor_col = cursor_col; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn scroll_copy_mode_page( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - direction: i16, - half_page: bool, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let mut cursor_row = copy_mode.cursor_row; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - let lines = copy_mode_page_lines(info.inner_rect.height, half_page); - if let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) { - if direction < 0 { - let next_offset = metrics.offset_from_bottom.saturating_add(lines); - if next_offset > metrics.max_offset_from_bottom { - let scrolled_lines = metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom); - let cursor_lines = lines.saturating_sub(scrolled_lines); - self.set_pane_scroll_offset( - terminal_runtimes, - pane_id, - metrics.max_offset_from_bottom, - ); - cursor_row = - cursor_row.saturating_sub(cursor_lines.min(u16::MAX as usize) as u16); - } else { - self.set_pane_scroll_offset(terminal_runtimes, pane_id, next_offset); - } - } else if metrics.offset_from_bottom < lines { - let cursor_lines = lines.saturating_sub(metrics.offset_from_bottom); - self.set_pane_scroll_offset(terminal_runtimes, pane_id, 0); - cursor_row = cursor_row - .saturating_add(cursor_lines.min(u16::MAX as usize) as u16) - .min(info.inner_rect.height.saturating_sub(1)); - } else { - self.set_pane_scroll_offset( - terminal_runtimes, - pane_id, - metrics.offset_from_bottom - lines, - ); - } - } else if direction < 0 { - self.scroll_pane_up(terminal_runtimes, pane_id, lines); - } else { - self.scroll_pane_down(terminal_runtimes, pane_id, lines); - } - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = cursor_row; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_history_top(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) else { - return; - }; - self.set_pane_scroll_offset(terminal_runtimes, pane_id, metrics.max_offset_from_bottom); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_history_bottom(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(info) = self.pane_info_by_id(pane_id) else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - let cursor_row = info.inner_rect.height.saturating_sub(1); - self.set_pane_scroll_offset(terminal_runtimes, pane_id, 0); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = cursor_row; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_line_edge(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, end: bool) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let cursor_row = copy_mode.cursor_row; - let Some(info) = self.pane_info_by_id(pane_id) else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - let cursor_col = if end { - let Some(text) = self.copy_mode_visible_row_text(terminal_runtimes, cursor_row) else { - return; - }; - last_character_col(&text) - .unwrap_or(0) - .min(info.inner_rect.width.saturating_sub(1)) - } else { - 0 - }; - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_col = cursor_col; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_first_non_blank(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let cursor_row = copy_mode.cursor_row; - let Some(text) = self.copy_mode_visible_row_text(terminal_runtimes, cursor_row) else { - return; - }; - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_col = first_non_blank_col(&text).unwrap_or(0); - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_word_motion( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - motion: WordMotion, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id) else { - return; - }; - let Some(ws_idx) = self.active else { - return; - }; - let Some(runtime) = - self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, copy_mode.pane_id) - else { - return; - }; - let absolute_row = - viewport_top_row(metrics).saturating_add(u32::from(copy_mode.cursor_row)); - let motion = match motion { - WordMotion::NextStart => crate::pane::TerminalWordMotion::NextStart, - WordMotion::PreviousStart => crate::pane::TerminalWordMotion::PreviousStart, - WordMotion::NextEnd => crate::pane::TerminalWordMotion::NextEnd, - WordMotion::NextBigStart => crate::pane::TerminalWordMotion::NextBigStart, - WordMotion::PreviousBigStart => crate::pane::TerminalWordMotion::PreviousBigStart, - WordMotion::NextBigEnd => crate::pane::TerminalWordMotion::NextBigEnd, - }; - let Some(target) = runtime.word_motion_target(absolute_row, copy_mode.cursor_col, motion) - else { - return; - }; - self.move_copy_cursor_to_absolute(terminal_runtimes, target, false); - } - - fn copy_mode_paragraph(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, direction: i16) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(pane_height) = self - .pane_info_by_id(pane_id) - .map(|info| info.inner_rect.height) - else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - let limit = self - .pane_scroll_metrics(terminal_runtimes, pane_id) - .map(|metrics| metrics.max_offset_from_bottom + metrics.viewport_rows) - .unwrap_or(pane_height as usize) - .clamp(1, 1000); - - for _ in 0..limit { - let before = self.copy_mode.as_ref().map(|copy_mode| { - ( - copy_mode.cursor_row, - copy_mode.cursor_col, - copy_mode.selection, - ) - }); - let before_offset = self - .pane_scroll_metrics(terminal_runtimes, pane_id) - .map(|metrics| metrics.offset_from_bottom); - - self.move_copy_cursor(terminal_runtimes, direction, 0); - - let Some(after) = self.copy_mode.as_ref() else { - return; - }; - if self - .copy_mode_visible_row_text(terminal_runtimes, after.cursor_row) - .is_some_and(|text| text.trim().is_empty()) - { - return; - } - - let Some(after_metrics) = self.pane_scroll_metrics(terminal_runtimes, after.pane_id) - else { - continue; - }; - let did_not_move = before - == self.copy_mode.as_ref().map(|copy_mode| { - ( - copy_mode.cursor_row, - copy_mode.cursor_col, - copy_mode.selection, - ) - }) - && before_offset == Some(after_metrics.offset_from_bottom); - let at_top = direction < 0 - && after.cursor_row == 0 - && after_metrics.offset_from_bottom == after_metrics.max_offset_from_bottom; - let at_bottom = direction > 0 - && after.cursor_row + 1 >= pane_height - && after_metrics.offset_from_bottom == 0; - if did_not_move || at_top || at_bottom { - return; - } - } - } - - fn copy_mode_visible_row_text( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - viewport_row: u16, - ) -> Option { - let copy_mode = self.copy_mode.as_ref()?; - let ws_idx = self.active?; - let info = self.pane_info_by_id(copy_mode.pane_id)?; - if viewport_row >= info.inner_rect.height || info.inner_rect.width == 0 { - return None; - } - let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id); - let row_selection = Selection::range( - copy_mode.pane_id, - viewport_row, - 0, - info.inner_rect.width.saturating_sub(1), - metrics, - ); - self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, copy_mode.pane_id)? - .extract_selection(&row_selection) - } - - pub(crate) fn copy_mode_pane_is_focused(&self) -> bool { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return false; - }; - self.current_pane_focus_target() - .is_some_and(|target| target.pane_id == copy_mode.pane_id) - } - - pub(crate) fn sync_copy_mode_with_focus(&mut self) { - if self.copy_mode.is_none() { - return; - } - if !matches!( - self.mode, - Mode::Copy | Mode::Terminal | Mode::Navigate | Mode::Prefix - ) { - return; - } - if self.copy_mode_pane_is_focused() { - self.mode = Mode::Copy; - } else if self.active.is_some() { - self.clear_copy_mode_selection(); - self.mode = Mode::Terminal; - } else { - self.clear_copy_mode_selection(); - self.mode = Mode::Navigate; - } - } - - pub(crate) fn settle_terminal_mode_after_focus(&mut self) { - self.mode = Mode::Terminal; - self.sync_copy_mode_with_focus(); - } - - pub(crate) fn sync_copy_mode_search_geometry(&mut self) { - let geometry = self.copy_mode.as_ref().and_then(|copy_mode| { - self.view - .pane_infos - .iter() - .find(|info| info.id == copy_mode.pane_id) - .map(|info| (info.inner_rect.width, info.inner_rect.height)) - }); - let Some(copy_mode) = self.copy_mode.as_mut() else { - return; - }; - if let Some(geometry) = geometry { - if copy_mode.search.geometry.is_some() && copy_mode.search.geometry != Some(geometry) { - copy_mode.search.matches.clear(); - copy_mode.search.current = None; - } - copy_mode.search.geometry = Some(geometry); - } - } - - pub(crate) fn clear_copy_mode_selection(&mut self) { - self.clear_selection(); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.selection = None; - } - } - - pub(crate) fn clear_copy_mode_for_removed_panes( - &mut self, - pane_ids: impl IntoIterator, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - if pane_ids - .into_iter() - .any(|pane_id| pane_id == copy_mode.pane_id) - { - self.clear_selection(); - self.copy_mode = None; - if self.mode == Mode::Copy { - self.mode = if self.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - } - - fn sync_copy_mode_selection(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let Some(selection) = copy_mode.selection else { - return; - }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id).cloned() else { - return; - }; - match selection { - CopyModeSelection::Character => { - let screen_col = info.inner_rect.x.saturating_add(copy_mode.cursor_col); - let screen_row = info.inner_rect.y.saturating_add(copy_mode.cursor_row); - self.update_selection_cursor( - terminal_runtimes, - copy_mode.pane_id, - screen_col, - screen_row, - ); - } - CopyModeSelection::Linewise { anchor_row } => { - let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id); - let cursor_row = - crate::selection::absolute_row_for_viewport(copy_mode.cursor_row, metrics); - self.selection = Some(Selection::line_range( - copy_mode.pane_id, - anchor_row, - cursor_row, - info.inner_rect.width.saturating_sub(1), - )); - } - } - } -} - -impl CopyModeSearchDirection { - fn reversed(self) -> Self { - match self { - Self::Forward => Self::Backward, - Self::Backward => Self::Forward, - } - } -} - -fn viewport_top_row(metrics: crate::pane::ScrollMetrics) -> u32 { - metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom) - .min(u32::MAX as usize) as u32 -} - -pub(crate) fn search_match_index( - matches: &[crate::pane::TerminalTextMatch], - direction: CopyModeSearchDirection, - cursor: crate::pane::TerminalTextPoint, - previous: Option, -) -> Option { - if matches.is_empty() { - return None; - } - match direction { - CopyModeSearchDirection::Forward => { - let origin = previous.map_or(cursor, |text_match| text_match.end); - matches - .iter() - .position(|text_match| text_match.start > origin) - .or(Some(0)) - } - CopyModeSearchDirection::Backward => { - let origin = previous.map_or(cursor, |text_match| text_match.start); - matches - .iter() - .rposition(|text_match| text_match.end < origin) - .or_else(|| matches.len().checked_sub(1)) - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WordMotion { - NextStart, - PreviousStart, - NextEnd, - NextBigStart, - PreviousBigStart, - NextBigEnd, -} - -pub(crate) fn first_non_blank_col(text: &str) -> Option { - let mut col = 0u16; - for ch in text.chars() { - if !ch.is_whitespace() { - return Some(col); - } - col = col.saturating_add(char_cell_width(ch)); - } - None -} - -pub(crate) fn last_character_col(text: &str) -> Option { - let mut col = 0u16; - let mut last_col = None; - for ch in text.chars() { - let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)); - if width > 0 { - last_col = Some(col); - col = col.saturating_add(width); - } - } - last_col -} - -fn char_cell_width(ch: char) -> u16 { - u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)).max(1) -} - -pub(crate) fn copy_mode_page_lines(height: u16, half_page: bool) -> usize { - if height <= 2 { - 1 - } else if half_page { - usize::from(height / 2) - } else { - usize::from(height - 2) - } -} - -pub(crate) fn copy_mode_command_char(key: TerminalKey) -> Option { - if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { - return None; - } - - if let Some(ch) = key.shifted_codepoint.and_then(char::from_u32) { - return Some(ch); - } - - let KeyCode::Char(ch) = key.code else { - return None; - }; - if key.modifiers.contains(KeyModifiers::SHIFT) { - Some(shifted_ascii_char(ch).unwrap_or(ch)) - } else { - Some(ch) - } -} - -fn shifted_ascii_char(ch: char) -> Option { - match ch { - 'a'..='z' => Some(ch.to_ascii_uppercase()), - '1' => Some('!'), - '2' => Some('@'), - '3' => Some('#'), - '4' => Some('$'), - '5' => Some('%'), - '6' => Some('^'), - '7' => Some('&'), - '8' => Some('*'), - '9' => Some('('), - '0' => Some(')'), - '-' => Some('_'), - '=' => Some('+'), - '[' => Some('{'), - ']' => Some('}'), - '\\' => Some('|'), - ';' => Some(':'), - '\'' => Some('"'), - ',' => Some('<'), - '.' => Some('>'), - '/' => Some('?'), - '`' => Some('~'), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::super::{app_for_mouse_test, numbered_lines_bytes}; - use super::*; - use crate::{events::AppEvent, workspace::Workspace}; - use ratatui::layout::Rect; - - fn app_with_copy_runtime( - runtime: impl FnOnce(u16, u16) -> crate::terminal::TerminalRuntime, - ) -> (App, crate::layout::PaneId) { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 20, 5)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( - pane_id, - runtime(info.inner_rect.width, info.inner_rect.height), - ); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - (app, pane_id) - } - - fn app_with_copy_screen(bytes: &[u8]) -> (App, crate::layout::PaneId) { - app_with_copy_runtime(|cols, rows| { - crate::terminal::TerminalRuntime::test_with_screen_bytes(cols, rows, bytes) - }) - } - - fn app_with_copy_scrollback(bytes: &[u8]) -> (App, crate::layout::PaneId) { - app_with_copy_runtime(|cols, rows| { - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - cols, - rows, - 16 * 1024, - bytes, - ) - }) - } - - fn app_with_split_copy_runtime( - bytes: &[u8], - first_runtime: impl FnOnce(u16, u16, &[u8]) -> crate::terminal::TerminalRuntime, - ) -> (App, crate::layout::PaneId, crate::layout::PaneId) { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal); - let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 40, 5)); - let first_info = pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info"); - let second_info = pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info"); - ws.tabs[0].runtimes.insert( - first_pane, - first_runtime( - first_info.inner_rect.width, - first_info.inner_rect.height, - bytes, - ), - ); - ws.tabs[0].runtimes.insert( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - second_info.inner_rect.width, - second_info.inner_rect.height, - b"", - ), - ); - ws.tabs[0].layout.focus_pane(first_pane); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - (app, first_pane, second_pane) - } - - fn app_with_split_copy_screen( - bytes: &[u8], - ) -> (App, crate::layout::PaneId, crate::layout::PaneId) { - app_with_split_copy_runtime(bytes, |cols, rows, bytes| { - crate::terminal::TerminalRuntime::test_with_screen_bytes(cols, rows, bytes) - }) - } - - fn app_with_split_copy_scrollback( - bytes: &[u8], - ) -> (App, crate::layout::PaneId, crate::layout::PaneId) { - app_with_split_copy_runtime(bytes, |cols, rows, bytes| { - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - cols, - rows, - 16 * 1024, - bytes, - ) - }) - } - - fn copy_mode_clipboard_text(app: &mut App) -> String { - match app.event_rx.try_recv().expect("clipboard event") { - AppEvent::ClipboardWrite { content } => { - String::from_utf8(content).expect("utf8 clipboard") - } - other => panic!("unexpected event: {other:?}"), - } - } - - fn copy_mode_viewport_top_row(app: &App, pane_id: crate::layout::PaneId) -> usize { - let metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("copy mode scroll metrics"); - metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom) - } - - fn copy_mode_offset_from_bottom(app: &App, pane_id: crate::layout::PaneId) -> usize { - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("copy mode scroll metrics") - .offset_from_bottom - } - - fn copy_mode_scroll_metrics( - app: &App, - pane_id: crate::layout::PaneId, - ) -> crate::pane::ScrollMetrics { - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("copy mode scroll metrics") - } - - fn refresh_split_pane_infos(app: &mut App) { - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .expect("active tab") - .layout - .panes(Rect::new(0, 0, 40, 5)); - } - - #[tokio::test] - async fn enter_copy_mode_tracks_focused_pane() { - let (mut app, pane_id) = app_with_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").pane_id, - pane_id - ); - } - - #[tokio::test] - async fn copy_mode_ctrl_b_uses_page_up() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.prefix_code = KeyCode::Char('a'); - app.state.prefix_mods = KeyModifiers::CONTROL; - app.state.enter_copy_mode(&app.terminal_runtimes); - let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; - let expected_lines = copy_mode_page_lines(height, false); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); - - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), expected_lines); - } - - #[tokio::test] - async fn copy_mode_prefix_takes_priority_over_ctrl_b_page_up() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - - assert_eq!(app.state.mode, Mode::Prefix); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - assert!(app.state.copy_mode.is_some()); - } - - #[tokio::test] - async fn copy_mode_prefix_escape_returns_to_copy_mode() { - let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode").clone(); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.copy_mode, Some(copy_mode)); - } - - #[tokio::test] - async fn copy_mode_prefix_focus_keeps_copy_mode_on_source_pane() { - let (mut app, first_pane, second_pane) = app_with_split_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode").clone(); - assert_eq!(copy_mode.pane_id, first_pane); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.copy_mode, Some(copy_mode.clone())); - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - second_pane - ); - - refresh_split_pane_infos(&mut app); - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.copy_mode, Some(copy_mode)); - assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), first_pane); - } - - #[tokio::test] - async fn copy_mode_focus_away_preserves_scrollback_position() { - let bytes = numbered_lines_bytes(64); - let (mut app, first_pane, second_pane) = app_with_split_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let scrolled_offset = copy_mode_offset_from_bottom(&app, first_pane); - assert!(scrolled_offset > 0); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - second_pane - ); - assert_eq!( - copy_mode_offset_from_bottom(&app, first_pane), - scrolled_offset - ); - } - - #[tokio::test] - async fn copy_mode_cancel_restores_scroll_after_workspace_switch() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state - .workspaces - .push(crate::workspace::Workspace::test_new("other")); - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); - - app.state.switch_workspace(1); - app.state.cancel_copy_mode(&app.terminal_runtimes); - - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - assert!(app.state.copy_mode.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn copy_mode_clears_when_source_tab_closes_after_focus_away() { - let (mut app, first_pane, _) = app_with_split_copy_screen(b"alpha\nbeta\n"); - let survivor_tab = app.state.workspaces[0].test_add_tab(Some("survivor")); - let survivor_pane = app.state.workspaces[0].tabs[survivor_tab].root_pane; - let survivor_terminal = app.state.workspaces[0].tabs[survivor_tab].panes[&survivor_pane] - .attached_terminal_id - .clone(); - app.state.terminals.insert( - survivor_terminal.clone(), - crate::terminal::TerminalState::new(survivor_terminal, "/tmp".into()), - ); - app.state.enter_copy_mode(&app.terminal_runtimes); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").pane_id, - first_pane - ); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())) - .await; - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_some()); - - assert!(!app.state.close_tab()); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - app.state.assert_invariants_for_test(); - } - - #[tokio::test] - async fn copy_mode_ctrl_f_uses_page_down() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; - let page_lines = copy_mode_page_lines(height, false); - app.state - .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, page_lines); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); - - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - } - - #[tokio::test] - async fn copy_mode_line_end_stops_at_last_character() { - let (mut app, _) = app_with_copy_screen(b"hello\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('$'), KeyModifiers::empty())); - - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_col = 0; - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::End, KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - - let (mut empty_app, _) = app_with_copy_screen(b"\r\n"); - empty_app - .state - .enter_copy_mode(&empty_app.terminal_runtimes); - if let Some(copy_mode) = empty_app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 7; - } - empty_app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('$'), KeyModifiers::empty())); - assert_eq!( - empty_app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .cursor_col, - 0 - ); - - let (mut wide_app, _) = app_with_copy_screen("a界\r\n".as_bytes()); - wide_app.state.enter_copy_mode(&wide_app.terminal_runtimes); - if let Some(copy_mode) = wide_app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - wide_app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('$'), KeyModifiers::empty())); - assert_eq!( - wide_app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .cursor_col, - 1 - ); - } - - #[tokio::test] - async fn copy_mode_word_motions_use_visible_row_words() { - let (mut app, _) = app_with_copy_screen(b"foo bar baz\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('e'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 6 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - } - - fn submit_copy_search(app: &mut App, marker: char, query: &str) { - app.handle_copy_mode_key(TerminalKey::new( - KeyCode::Char(marker), - KeyModifiers::empty(), - )); - for ch in query.chars() { - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char(ch), KeyModifiers::empty())); - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Enter, KeyModifiers::empty())); - } - - #[tokio::test] - async fn copy_mode_search_wraps_and_repeats_in_both_directions() { - let (mut app, pane_id) = app_with_copy_screen(b"alpha needle\r\nbeta needle\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - - submit_copy_search(&mut app, '/', "needle"); - - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.matches.len(), 2); - assert_eq!(copy_mode.search.current, Some(0)); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - assert_eq!(copy_mode.cursor_col, 6); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty())); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.current, Some(1)); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 1 - ); - assert_eq!(copy_mode.cursor_col, 5); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::SHIFT)); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.current, Some(0)); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty())); - assert_eq!( - app.state - .copy_mode - .as_ref() - .expect("copy mode") - .search - .current, - Some(1) - ); - } - - #[tokio::test] - async fn copy_mode_backward_search_uses_last_match_before_cursor() { - let (mut app, pane_id) = app_with_copy_screen(b"alpha needle\r\nbeta needle\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - - submit_copy_search(&mut app, '?', "needle"); - - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.current, Some(1)); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 1 - ); - } - - #[tokio::test] - async fn copy_mode_shifted_slash_opens_backward_search() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - - app.handle_copy_mode_key( - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!( - app.state - .copy_mode - .as_ref() - .and_then(|copy_mode| copy_mode.search.prompt.as_ref()) - .map(|prompt| prompt.direction), - Some(CopyModeSearchDirection::Backward) - ); - } - - #[tokio::test] - async fn copy_mode_search_extends_an_active_selection() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - - submit_copy_search(&mut app, '/', "needle"); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta n"); - } - - #[tokio::test] - async fn copy_mode_search_prompt_escape_and_failure_preserve_copy_mode() { - let (mut app, pane_id) = app_with_copy_screen(b"alpha\r\nbeta\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - let before = ( - copy_mode_viewport_top_row(&app, pane_id), - app.state.copy_mode.as_ref().expect("copy mode").cursor_row, - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - assert_eq!(app.state.mode, Mode::Copy); - assert!(app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .search - .prompt - .is_none()); - - submit_copy_search(&mut app, '/', "missing"); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.query, "missing"); - assert!(copy_mode.search.matches.is_empty()); - assert_eq!( - ( - copy_mode_viewport_top_row(&app, pane_id), - copy_mode.cursor_row, - copy_mode.cursor_col, - ), - before - ); - } - - #[tokio::test] - async fn copy_mode_escape_clears_search_before_exiting() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - submit_copy_search(&mut app, '/', "needle"); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - - let search = &app.state.copy_mode.as_ref().expect("copy mode").search; - assert!(search.query.is_empty()); - assert!(search.matches.is_empty()); - assert!(search.current.is_none()); - assert_eq!(app.state.mode, Mode::Copy); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - } - - #[tokio::test] - async fn copy_mode_escape_clears_selection_before_exiting() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - assert!(app.state.selection.is_some()); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::Copy); - assert!(app.state.selection.is_none()); - assert!(app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .selection - .is_none()); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn copy_mode_search_prompt_accepts_paste_without_exiting() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty())); - - assert!(app.paste_into_active_text_input("needle")); - assert_eq!( - app.state - .copy_mode - .as_ref() - .and_then(|copy_mode| copy_mode.search.prompt.as_ref()) - .map(|prompt| prompt.query.as_str()), - Some("needle") - ); - assert_eq!(app.state.mode, Mode::Copy); - } - - #[tokio::test] - async fn copy_mode_word_motions_cross_rows_and_respect_separators() { - let (mut app, pane_id) = app_with_copy_screen(b"foo.bar_baz\r\nqux\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - for expected_col in [3, 4] { - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - expected_col - ); - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 1 - ); - assert_eq!(copy_mode.cursor_col, 0); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::empty())); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - assert_eq!(copy_mode.cursor_col, 4); - } - - #[tokio::test] - async fn copy_mode_big_word_motions_skip_punctuation_runs() { - let (mut app, _) = app_with_copy_screen(b"foo.bar baz qux\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - for expected_col in [8, 12] { - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - expected_col - ); - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('e'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 14 - ); - for expected_col in [12, 8, 0] { - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - expected_col - ); - } - - // Lowercase motions keep their punctuation-aware behavior. - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 3 - ); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - } - - #[tokio::test] - async fn copy_mode_big_word_motions_accept_shifted_codepoints_and_cross_rows() { - let (mut app, pane_id) = app_with_copy_screen(b"foo.bar baz\r\nqux/quux\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - app.handle_copy_mode_key( - TerminalKey::new(KeyCode::Char('W'), KeyModifiers::SHIFT) - .with_shifted_codepoint('W' as u32), - ); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - assert_eq!(copy_mode.cursor_col, 8); - - app.handle_copy_mode_key( - TerminalKey::new(KeyCode::Char('W'), KeyModifiers::SHIFT) - .with_shifted_codepoint('W' as u32), - ); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 1 - ); - assert_eq!(copy_mode.cursor_col, 0); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::SHIFT)); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - assert_eq!(copy_mode.cursor_col, 8); - } - - #[tokio::test] - async fn copy_mode_big_word_motions_extend_an_active_selection() { - let (mut app, _) = app_with_copy_screen(b"foo.bar baz qux\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "foo.bar b"); - } - - #[tokio::test] - async fn copy_mode_search_does_not_change_live_follow_behavior() { - let bytes = numbered_lines_bytes(32); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - submit_copy_search(&mut app, '/', "missing"); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .expect("runtime") - .test_process_pty_bytes(b"live output\r\n"); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - - submit_copy_search(&mut app, '?', "000000"); - assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - let visible_before = runtime.visible_text(); - runtime.test_process_pty_bytes(b"more output\r\n"); - assert_eq!(runtime.visible_text(), visible_before); - } - - #[tokio::test] - async fn copy_mode_resize_clears_matches_but_keeps_query() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - submit_copy_search(&mut app, '/', "needle"); - assert!(!app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .search - .matches - .is_empty()); - - app.state.view.pane_infos[0].inner_rect.width = app.state.view.pane_infos[0] - .inner_rect - .width - .saturating_sub(1); - app.state.sync_copy_mode_search_geometry(); - - let search = &app.state.copy_mode.as_ref().expect("copy mode").search; - assert_eq!(search.query, "needle"); - assert!(search.matches.is_empty()); - assert!(search.current.is_none()); - } - - #[tokio::test] - async fn copy_mode_hidden_source_does_not_look_like_a_resize() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - submit_copy_search(&mut app, '/', "needle"); - let before = app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .search - .clone(); - - app.state.view.pane_infos.clear(); - app.state.sync_copy_mode_search_geometry(); - - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").search, - before - ); - } - - #[tokio::test] - async fn copy_mode_shift_v_y_copies_visible_line() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 1; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "beta"); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn copy_mode_shift_v_extends_linewise_down() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta"); - } - - #[tokio::test] - async fn copy_mode_shift_v_extends_linewise_up() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 1; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta"); - } - - #[tokio::test] - async fn copy_mode_shift_v_reverses_without_character_tail() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 1; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta"); - } - - #[tokio::test] - async fn copy_mode_shift_v_horizontal_motion_keeps_linewise_selection() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 1; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "beta"); - } - - #[tokio::test] - async fn copy_mode_shift_v_page_up_keeps_linewise_scrollback_selection() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 2; - } - - let anchor_row = copy_mode_viewport_top_row(&app, pane_id); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let cursor_row = copy_mode_viewport_top_row(&app, pane_id); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert!(cursor_row < anchor_row); - let expected = (cursor_row..=anchor_row) - .map(|row| format!("{row:06}")) - .collect::>() - .join("\n"); - assert_eq!(copy_mode_clipboard_text(&mut app), expected); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - } - - #[tokio::test] - async fn copy_mode_page_up_uses_tmux_page_size() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; - let expected_lines = copy_mode_page_lines(height, false); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), expected_lines); - } - - #[tokio::test] - async fn copy_mode_ctrl_u_moves_cursor_when_history_top_clamps() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - let bottom = app.state.copy_mode.as_ref().expect("copy mode").cursor_row; - let lines = copy_mode_page_lines(bottom + 1, true); - let metrics = copy_mode_scroll_metrics(&app, pane_id); - assert!(metrics.max_offset_from_bottom >= lines); - app.state.set_pane_scroll_offset( - &app.terminal_runtimes, - pane_id, - metrics.max_offset_from_bottom - lines + 1, - ); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = bottom; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - let expected_cursor_delta = 1; - assert_eq!( - copy_mode_offset_from_bottom(&app, pane_id), - metrics.max_offset_from_bottom - ); - assert_eq!( - copy_mode.cursor_row, - bottom.saturating_sub(expected_cursor_delta as u16) - ); - } - - #[tokio::test] - async fn copy_mode_ctrl_d_moves_cursor_when_live_bottom_clamps() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - let bottom = app.state.copy_mode.as_ref().expect("copy mode").cursor_row; - let lines = copy_mode_page_lines(bottom + 1, true); - assert!(lines > 1); - app.state - .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, lines - 1); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); - - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - assert_eq!(copy_mode.cursor_row, 1); - } - - #[tokio::test] - async fn copy_mode_q_exits_and_returns_to_bottom_after_scrollback() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('q'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - } - - #[tokio::test] - async fn copy_mode_q_restores_entry_scrollback_offset() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - let entry_offset = 3; - app.state - .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, entry_offset); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), entry_offset); - - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - assert!(copy_mode_offset_from_bottom(&app, pane_id) > entry_offset); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('q'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), entry_offset); - } - - #[tokio::test] - async fn shifted_punctuation_keys_work_with_enhanced_key_reporting() { - let (mut app, _) = app_with_copy_screen(b"foo\r\n\r\nbar\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 2; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('6'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 0 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char(']'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_row, - 3 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('['), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_row, - 1 - ); - - app.handle_copy_mode_key( - TerminalKey::new(KeyCode::Char(']'), KeyModifiers::SHIFT) - .with_shifted_codepoint('}' as u32), - ); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_row, - 3 - ); - } - - #[tokio::test] - async fn copy_mode_v_y_copies_selection_and_exits() { - let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - match app.event_rx.try_recv().expect("clipboard event") { - AppEvent::ClipboardWrite { content } => assert_eq!(content, b"alp"), - other => panic!("unexpected event: {other:?}"), - } - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - } - - #[tokio::test] - async fn copy_mode_same_tab_switch_preserves_selection() { - let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - - assert!(app.state.switch_workspace_tab(0, 0)); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - assert_eq!(copy_mode_clipboard_text(&mut app), "alp"); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - } -} diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs deleted file mode 100644 index b22943dd..00000000 --- a/src/app/input/mod.rs +++ /dev/null @@ -1,973 +0,0 @@ -//! Input handling — translates crossterm key/mouse events into state mutations. - -use bytes::Bytes; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; -use tracing::warn; - -use crate::app::PaneClickState; -#[cfg(test)] -use crate::input::TerminalKey; -#[cfg(test)] -use ratatui::layout::Direction; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ScrollbarClickTarget { - Thumb { grab_row_offset: u16 }, - Track { offset_from_bottom: usize }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg(test)] -enum WheelRouting { - HostScroll, - MouseReport, - AlternateScroll, -} - -const WORKSPACE_DRAG_THRESHOLD: u16 = 1; -const TAB_DRAG_THRESHOLD: u16 = 1; - -fn modified_url_click_modifier() -> KeyModifiers { - KeyModifiers::CONTROL -} - -#[cfg(test)] -#[test] -fn modified_url_click_modifier_matches_terminal_mouse_reporting() { - assert_eq!(modified_url_click_modifier(), KeyModifiers::CONTROL); -} - -mod clipboard; -pub(crate) mod copy_mode; -mod modal; -mod mouse; -mod navigate; -mod overlays; -mod selection; -mod settings; -mod sidebar; -mod terminal; - -pub(crate) use self::{ - modal::{ - handle_global_menu_key, handle_keybind_help_key, handle_navigator_key, - insert_keybind_help_query_text, insert_navigator_search_text, insert_rename_input_text, - open_new_workspace_dialog, - }, - navigate::{ - terminal_direct_indexed_navigation_action, terminal_direct_non_indexed_navigation_action, - }, - settings::open_settings_at, -}; -pub(crate) type ConsumedInputLease = crate::input::ConsumedInputLease; -pub(crate) type ForwardedInputLease = crate::input::ForwardedInputLease; -pub(crate) type InputLeaseKey = crate::input::InputLeaseKey; -pub(crate) type InputLeaseTable = crate::input::InputLeaseTable< - super::InputSourceId, - super::TerminalInputContext, - super::TerminalInputTarget, ->; -pub(crate) type RepeatPlan = - crate::input::RepeatPlan; -use self::{ - modal::{ - modal_action_from_key, ModalAction, ONBOARDING_WELCOME_ACTIONS, RELEASE_NOTES_ACTIONS, - }, - mouse::MouseAction, - settings::SettingsAction, -}; -use super::state::{AppState, Mode}; -use super::App; - -// --------------------------------------------------------------------------- -// Key handling -// --------------------------------------------------------------------------- - -impl App { - #[cfg(test)] - pub(super) async fn handle_key( - &mut self, - key: TerminalKey, - ) -> Option { - self.route_client_events(vec![crate::raw_input::RawInputEvent::Key(key)], true); - None - } - - pub(crate) fn handle_text_commit_headless(&mut self, text: &str) { - if text.is_empty() { - return; - } - if self.state.popup_pane.is_some() { - if let Some(runtime) = self.popup_runtime() { - let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes())); - } else { - self.close_popup_pane(); - } - return; - } - if self.state.mode != Mode::Terminal { - self.paste_into_active_text_input(text); - return; - } - - self.state.clear_selection(); - self.selection_autoscroll_deadline = None; - self.state.update_dismissed = true; - if let Some(ws_idx) = self.state.active { - if let Some(runtime) = self - .state - .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) - { - let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes())); - } - } - } - - #[cfg(test)] - pub(super) async fn handle_paste(&mut self, text: String) { - self.route_client_events(vec![crate::raw_input::RawInputEvent::Paste(text)], true); - } - - pub(crate) fn paste_into_active_text_input(&mut self, text: &str) -> bool { - match self.state.mode { - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { - insert_rename_input_text(&mut self.state, text); - true - } - Mode::NewLinkedWorktree => { - self.insert_worktree_create_text(text); - true - } - Mode::OpenExistingWorktree => { - if !self - .state - .worktree_open - .as_ref() - .is_some_and(|open| open.search_focused) - { - return false; - } - self.insert_worktree_open_search_text(text); - true - } - Mode::Navigator => { - if !self.state.navigator.search_focused { - return false; - } - insert_navigator_search_text(&mut self.state, &self.terminal_runtimes, text); - true - } - Mode::KeybindHelp => { - if !self.state.keybind_help.search_focused { - return false; - } - insert_keybind_help_query_text(&mut self.state, text); - true - } - Mode::Copy => { - let Some(prompt) = self - .state - .copy_mode - .as_mut() - .and_then(|copy_mode| copy_mode.search.prompt.as_mut()) - else { - return false; - }; - prompt - .query - .extend(text.chars().filter(|ch| !ch.is_control())); - true - } - _ => false, - } - } - - pub(crate) fn handle_onboarding_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Right | KeyCode::Char('l') => self.open_settings_from_onboarding(), - _ => { - if let Some(ModalAction::Continue) = - modal_action_from_key(&key, ONBOARDING_WELCOME_ACTIONS) - { - self.open_settings_from_onboarding(); - } - } - } - } - - pub(crate) fn handle_release_notes_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1), - KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1), - KeyCode::PageUp => self.scroll_release_notes(-8), - KeyCode::PageDown => self.scroll_release_notes(8), - KeyCode::Home => { - if let Some(notes) = &mut self.state.release_notes { - notes.scroll = 0; - } - } - KeyCode::End => { - let max_scroll = self.state.release_notes_max_scroll(); - if let Some(notes) = &mut self.state.release_notes { - notes.scroll = max_scroll; - } - } - _ => { - if let Some(ModalAction::Close) = modal_action_from_key(&key, RELEASE_NOTES_ACTIONS) - { - self.dismiss_release_notes(); - } - } - } - } - - pub(crate) fn handle_product_announcement_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Up | KeyCode::Char('k') => self.scroll_product_announcement(-1), - KeyCode::Down | KeyCode::Char('j') => self.scroll_product_announcement(1), - KeyCode::PageUp => self.scroll_product_announcement(-8), - KeyCode::PageDown => self.scroll_product_announcement(8), - KeyCode::Home => { - if let Some(announcement) = &mut self.state.product_announcement { - announcement.scroll = 0; - } - } - KeyCode::End => { - let max_scroll = self.state.product_announcement_max_scroll(); - if let Some(announcement) = &mut self.state.product_announcement { - announcement.scroll = max_scroll; - } - } - _ => { - if let Some(ModalAction::Close) = modal_action_from_key(&key, RELEASE_NOTES_ACTIONS) - { - self.dismiss_product_announcement(); - } - } - } - } - - #[cfg(test)] - pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { - self.handle_mouse_from_input_source(super::LOCAL_INPUT_SOURCE, mouse); - } - - pub(super) fn handle_mouse_from_input_source( - &mut self, - source_id: super::InputSourceId, - mouse: MouseEvent, - ) { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - self.pending_url_click_sources.remove(&source_id); - } - MouseEventKind::Drag(MouseButton::Left) - if self.pending_url_click_sources.contains(&source_id) => - { - return; - } - MouseEventKind::Up(MouseButton::Left) - if self.pending_url_click_sources.remove(&source_id) => - { - return; - } - _ => {} - } - - if self.state.popup_pane.is_some() { - self.handle_popup_mouse(mouse); - return; - } - if self.handle_overlay_mouse(mouse) { - return; - } - - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - && self.state.on_sidebar_divider(mouse.column, mouse.row) - { - let now = std::time::Instant::now(); - let is_double_click = self - .last_sidebar_divider_click - .is_some_and(|last| now.duration_since(last) <= super::SIDEBAR_DOUBLE_CLICK_WINDOW); - self.last_sidebar_divider_click = Some(now); - - if is_double_click { - self.state.sidebar_width = self.state.default_sidebar_width; - self.state.sidebar_width_source = - crate::app::state::SidebarWidthSource::ConfigDefault; - self.state.sidebar_width_auto = false; - self.state.mark_session_dirty(); - self.state.drag = None; - return; - } - } - - if self.handle_modified_url_click(source_id, mouse) { - return; - } - - let handled_pane_double_click = self.handle_pane_double_click(mouse); - if !handled_pane_double_click { - self.focus_pane_before_mouse_press(mouse); - } - - let previous_agent_panel_sort = self.state.agent_panel_sort; - let previous_settings_section = self.state.settings.section; - if !handled_pane_double_click { - if let Some(action) = - self.state - .handle_mouse(&mut self.terminal_runtimes, source_id, mouse) - { - match action { - MouseAction::NewWorkspace => { - self.begin_tui_workspace_create("tui.mouse.workspace.create") - } - MouseAction::Settings(action) => match action { - SettingsAction::SaveTheme(name) => self.save_theme(&name), - SettingsAction::SaveStatusIndicators(style) => { - self.save_status_indicators(style) - } - SettingsAction::SaveSound(enabled) => self.save_sound(enabled), - SettingsAction::SaveToastDelivery(delivery) => { - self.save_toast_delivery(delivery) - } - SettingsAction::SaveAgentBorderLabels(enabled) => { - self.save_agent_border_labels(enabled) - } - SettingsAction::InstallRecommendedIntegrations => { - self.install_recommended_integrations() - } - }, - MouseAction::FocusWorkspace { ws_idx } => { - self.focus_workspace_idx_via_api(ws_idx) - } - MouseAction::FocusTab { tab_idx } => self.focus_tab_idx_via_api(tab_idx), - MouseAction::FocusPane { ws_idx, pane_id } => { - self.focus_pane_internal_via_api(ws_idx, pane_id) - } - MouseAction::FocusToastTarget => self.focus_toast_target_via_api(), - MouseAction::MoveWorkspace { - source_ws_idx, - insert_idx, - } => self.move_workspace_via_api(source_ws_idx, insert_idx), - MouseAction::MoveWorkspaceBlock { params } => { - self.move_workspace_block_via_api(params) - } - MouseAction::MoveTab { - ws_idx, - source_tab_idx, - insert_idx, - } => self.move_tab_via_api(ws_idx, source_tab_idx, insert_idx), - MouseAction::SetSplitRatio { path, ratio } => { - self.set_split_ratio_via_api(path, ratio) - } - MouseAction::RenameModal(action) => { - self.apply_rename_mouse_action_via_api(action) - } - MouseAction::ConfirmCloseAccept => self.confirm_close_accept_via_api(), - MouseAction::ContextMenu { menu, idx } => { - self.apply_context_menu_action_via_api(menu, idx) - } - } - } - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - && self - .state - .selection - .as_ref() - .is_none_or(crate::selection::Selection::is_in_progress) - { - self.selection_highlight_clear_deadline = None; - } - } - if previous_settings_section != crate::app::state::SettingsSection::Integrations - && self.state.settings.section == crate::app::state::SettingsSection::Integrations - { - self.refresh_integration_recommendations(); - } - if self.state.agent_panel_sort != previous_agent_panel_sort { - self.save_agent_panel_sort(self.state.agent_panel_sort); - } - - self.dispatch_pending_clipboard_write(); - - // Sync autoscroll deadline with state (mouse handler may have - // set or cleared selection_autoscroll during handle_mouse). - if self.state.selection_autoscroll.is_none() { - self.selection_autoscroll_deadline = None; - } else if self.selection_autoscroll_deadline.is_none() { - self.selection_autoscroll_deadline = - Some(std::time::Instant::now() + super::SELECTION_AUTOSCROLL_INTERVAL); - } - } - - fn handle_popup_mouse(&mut self, mouse: MouseEvent) { - let Some((_outer, inner)) = - crate::ui::popup_pane_rects(&self.state, self.state.view.terminal_area) - else { - return; - }; - if mouse.column < inner.x - || mouse.column >= inner.x.saturating_add(inner.width) - || mouse.row < inner.y - || mouse.row >= inner.y.saturating_add(inner.height) - { - return; - } - let Some(rt) = self.popup_runtime() else { - self.close_popup_pane(); - return; - }; - let position = crate::input::mouse::Position::Cell { - column: mouse.column.saturating_sub(inner.x), - row: mouse.row.saturating_sub(inner.y), - }; - let bytes = match mouse.kind { - MouseEventKind::ScrollUp - | MouseEventKind::ScrollDown - | MouseEventKind::ScrollLeft - | MouseEventKind::ScrollRight => match rt.wheel_routing() { - Some(crate::pane::WheelRouting::MouseReport) => { - rt.encode_mouse_wheel(mouse.kind, position, mouse.modifiers) - } - Some(crate::pane::WheelRouting::AlternateScroll) => { - rt.encode_alternate_scroll(mouse.kind) - } - Some(crate::pane::WheelRouting::HostScroll) | None => { - let lines_per_notch = self.state.mouse_scroll_lines; - match mouse.kind { - MouseEventKind::ScrollUp => rt.scroll_up(lines_per_notch), - MouseEventKind::ScrollDown => rt.scroll_down(lines_per_notch), - _ => {} - } - return; - } - }, - MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { - rt.encode_mouse_button(mouse.kind, position, mouse.modifiers) - } - MouseEventKind::Moved => rt.encode_mouse_motion(mouse.kind, position, mouse.modifiers), - }; - let Some(bytes) = bytes else { - return; - }; - if !matches!(mouse.kind, MouseEventKind::Moved) { - rt.scroll_reset(); - } - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(err = %err, kind = ?mouse.kind, "failed to forward popup mouse event"); - } - } - - fn focus_pane_before_mouse_press(&mut self, mouse: MouseEvent) { - if !matches!(self.state.mode, Mode::Terminal | Mode::Resize) - || !matches!( - mouse.kind, - MouseEventKind::Down(MouseButton::Left | MouseButton::Middle) - ) - { - return; - } - - let Some(pane_id) = self - .state - .pane_at(mouse.column, mouse.row) - .map(|info| info.id) - else { - return; - }; - let Some(ws_idx) = self.state.active else { - return; - }; - - // Focus through the runtime API before an application can consume its press. - self.focus_pane_internal_via_api(ws_idx, pane_id); - } - - fn handle_modified_url_click( - &mut self, - source_id: super::InputSourceId, - mouse: MouseEvent, - ) -> bool { - self.handle_modified_url_click_with(source_id, mouse, crate::platform::open_url) - } - - fn handle_modified_url_click_with( - &mut self, - source_id: super::InputSourceId, - mouse: MouseEvent, - open_url: impl FnOnce(&str) -> std::io::Result>, - ) -> bool { - if self.state.mode != Mode::Terminal - || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - || !mouse.modifiers.contains(modified_url_click_modifier()) - { - return false; - } - - let Some(info) = self.state.pane_at(mouse.column, mouse.row).cloned() else { - return false; - }; - let viewport_row = mouse.row.saturating_sub(info.inner_rect.y); - let col = mouse.column.saturating_sub(info.inner_rect.x); - let Some(url) = - self.state - .url_at_pane_cell(&self.terminal_runtimes, info.id, viewport_row, col) - else { - return false; - }; - - let plugin_handled = match self.invoke_plugin_link_handler_for_url(&url, info.id) { - Ok(handled) => handled, - Err(err) => { - tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler"); - false - } - }; - if !plugin_handled && crate::app::actions::safe_web_url(&url).is_none() { - return false; - } - - self.last_pane_click = None; - self.pending_url_click_sources.insert(source_id); - if plugin_handled { - return true; - } - match open_url(&url) { - Ok(Some(child)) => self.detached_process_children.push(child), - Ok(None) => {} - Err(err) => { - tracing::warn!(err = %err, url = %url, "failed to open pane URL"); - } - } - true - } - - fn handle_pane_double_click(&mut self, mouse: MouseEvent) -> bool { - // A pane press stops being a double-click candidate once it becomes - // a drag or completes as a real text selection. - match mouse.kind { - MouseEventKind::Drag(MouseButton::Left) => { - self.last_pane_click = None; - return false; - } - MouseEventKind::Up(MouseButton::Left) - if self - .state - .selection - .as_ref() - .is_some_and(|selection| selection.is_visible()) => - { - self.last_pane_click = None; - return false; - } - _ => {} - } - - // Only terminal-pane left-clicks can start this gesture; other clicks - // should keep their existing mouse behavior and clear stale candidates. - let Some(click) = self.pane_click_candidate(mouse) else { - return false; - }; - - // Require the second click to land near the first click in the same pane - // and within the double-click window so adjacent interactions do not select a word. - if !self.take_pane_double_click(click) { - return false; - } - - self.select_double_clicked_word(click) - } - - fn pane_click_candidate(&mut self, mouse: MouseEvent) -> Option { - if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - return None; - } - - if !mouse.modifiers.is_empty() { - self.last_pane_click = None; - return None; - } - - if self.state.mode != Mode::Terminal { - self.last_pane_click = None; - return None; - } - - let Some(info) = self.state.pane_at(mouse.column, mouse.row).cloned() else { - self.last_pane_click = None; - return None; - }; - - Some(PaneClickState { - pane_id: info.id, - viewport_row: mouse.row - info.inner_rect.y, - col: mouse.column - info.inner_rect.x, - at: std::time::Instant::now(), - }) - } - - fn take_pane_double_click(&mut self, click: PaneClickState) -> bool { - if !self - .last_pane_click - .is_some_and(|last| last.is_double_click_for(click)) - { - self.last_pane_click = Some(click); - return false; - } - - self.last_pane_click = None; - true - } - - fn select_double_clicked_word(&mut self, click: PaneClickState) -> bool { - let selected = self.state.select_word_at_pane_cell( - &self.terminal_runtimes, - click.pane_id, - click.viewport_row, - click.col, - ); - if selected { - self.selection_highlight_clear_deadline = self - .state - .copy_on_select - .then(|| std::time::Instant::now() + super::PANE_COPY_HIGHLIGHT_DURATION); - } - selected - } -} - -pub(crate) fn is_modal_paste_shortcut(key: &KeyEvent) -> bool { - if !matches!(key.code, KeyCode::Char('v' | 'V')) { - return false; - } - - #[cfg(target_os = "macos")] - { - key.modifiers.contains(KeyModifiers::SUPER) || key.modifiers.contains(KeyModifiers::CONTROL) - } - - #[cfg(not(target_os = "macos"))] - { - key.modifiers.contains(KeyModifiers::CONTROL) - } -} - -pub(crate) fn modal_paste_target_active(state: &AppState) -> bool { - match state.mode { - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane | Mode::NewLinkedWorktree => { - true - } - Mode::OpenExistingWorktree => state - .worktree_open - .as_ref() - .is_some_and(|open| open.search_focused), - Mode::Navigator => state.navigator.search_focused, - Mode::KeybindHelp => state.keybind_help.search_focused, - Mode::Copy => state - .copy_mode - .as_ref() - .is_some_and(|copy_mode| copy_mode.search.prompt.is_some()), - _ => false, - } -} - -// --------------------------------------------------------------------------- -// Mouse handling -// --------------------------------------------------------------------------- - -// Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App -impl AppState { - #[cfg(test)] - pub(crate) fn split_pane( - &mut self, - terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, - direction: Direction, - ) { - // Actual PTY spawning happens in Workspace::split_focused - // which needs events channel — this is called from navigate_key - // where we don't have async context, so the workspace handles it - let (rows, cols) = self.estimate_pane_size(); - let new_rows = (rows / 2).max(4); - let new_cols = (cols / 2).max(10); - - let follow_cwd = self - .active - .and_then(|i| self.workspaces.get(i)) - .and_then(|ws| { - let tab = ws.active_tab()?; - let terminal_id = tab.terminal_id(tab.layout.focused())?; - super::creation::launch_cwd_for_terminal( - terminal_id, - &self.terminals, - terminal_runtimes, - ) - }); - let cwd = Some(super::creation::resolve_new_terminal_cwd( - &self.new_terminal_cwd, - follow_cwd, - )); - - let previous_focus = self.current_pane_focus_target(); - if let Some(ws_idx) = self.active { - let Some(ws) = self.workspaces.get_mut(ws_idx) else { - return; - }; - if let Ok(new_pane) = ws.split_focused( - direction, - new_rows, - new_cols, - cwd, - self.pane_scrollback_limit_bytes, - self.host_terminal_theme, - self.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&self.default_shell, self.shell_mode), - Vec::new(), - ) { - let new_id = new_pane.pane_id; - terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime); - self.remove_alias_shadowed_by_new_pane(new_id); - self.terminals - .insert(new_pane.terminal.id.clone(), new_pane.terminal); - self.record_pane_focus_change(previous_focus, ws_idx, new_id); - self.mark_session_dirty(); - self.mode = Mode::Terminal; - } - } - } -} - -#[cfg(test)] -fn state_with_workspaces(names: &[&str]) -> AppState { - let mut state = AppState::test_new(); - state.workspaces = names - .iter() - .map(|name| crate::workspace::Workspace::test_new(name)) - .collect(); - if !state.workspaces.is_empty() { - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Navigate; - } - state -} - -#[cfg(test)] -fn app_for_mouse_test() -> App { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &crate::config::Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.mode = Mode::Terminal; - app.state.update_available = None; - app.state.latest_release_notes_available = false; - app.state.view.sidebar_rect = ratatui::layout::Rect::new(0, 0, 26, 20); - app.state.view.terminal_area = ratatui::layout::Rect::new(26, 0, 80, 20); - app -} - -#[cfg(test)] -fn mouse( - kind: crossterm::event::MouseEventKind, - col: u16, - row: u16, -) -> crossterm::event::MouseEvent { - crossterm::event::MouseEvent { - kind, - column: col, - row, - modifiers: crossterm::event::KeyModifiers::empty(), - } -} - -#[cfg(test)] -fn numbered_lines_bytes(count: usize) -> Vec { - (0..count) - .map(|i| format!("{i:06}\r\n")) - .collect::() - .into_bytes() -} - -#[cfg(test)] -fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - crate::persist::capture( - &state.workspaces, - &state.terminals, - &terminal_runtimes, - state.active, - state.selected, - state.sidebar_width, - state.sidebar_section_split, - state.collapsed_space_keys.clone(), - ) -} - -#[cfg(test)] -fn root_layout_ratio(snapshot: &crate::persist::SessionSnapshot) -> Option { - match &snapshot.workspaces.first()?.tabs.first()?.layout { - crate::persist::LayoutSnapshot::Split { ratio, .. } => Some(*ratio), - crate::persist::LayoutSnapshot::Pane(_) => None, - } -} - -#[cfg(test)] -fn unique_temp_path(name: &str) -> std::path::PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) -} - -#[cfg(test)] -#[cfg(unix)] -fn wait_for_file(path: &std::path::Path) -> String { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while std::time::Instant::now() < deadline { - if let Ok(content) = std::fs::read_to_string(path) { - if !content.is_empty() { - return content; - } - } - std::thread::sleep(std::time::Duration::from_millis(20)); - } - panic!("timed out waiting for {}", path.display()); -} - -#[cfg(test)] -#[cfg(unix)] -async fn wait_for_detached_process_reap(app: &mut App, pid: u32) -> bool { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); - while crate::platform::process_exists(pid) && tokio::time::Instant::now() < deadline { - app.reap_finished_detached_processes(); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } - app.reap_finished_detached_processes(); - !crate::platform::process_exists(pid) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_app() -> App { - App::new( - &crate::config::Config::default(), - true, - None, - tokio::sync::mpsc::unbounded_channel().1, - crate::api::EventHub::default(), - ) - } - - #[tokio::test] - async fn paste_routes_to_rename_modal_input() { - let mut app = test_app(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameTab; - app.state.name_input = "2".into(); - app.state.name_input_replace_on_type = true; - - app.handle_paste("feature/logs".into()).await; - - assert_eq!(app.state.name_input, "feature/logs"); - assert!(!app.state.name_input_replace_on_type); - } - - #[tokio::test] - async fn paste_routes_to_keybind_help_query_only_when_searching() { - let mut app = test_app(); - app.state.mode = Mode::KeybindHelp; - app.handle_paste("ignored".into()).await; - assert!(app.state.keybind_help.query.is_empty()); - - app.state.keybind_help.search_focused = true; - app.state.keybind_help.scroll = 3; - app.handle_paste("work\nspace".into()).await; - - assert_eq!(app.state.keybind_help.query, "workspace"); - assert_eq!(app.state.keybind_help.scroll, 0); - } - - #[tokio::test] - async fn paste_routes_to_new_linked_worktree_input() { - let mut app = test_app(); - app.state.mode = Mode::NewLinkedWorktree; - app.state.name_input = "generated-branch".into(); - app.state.name_input_replace_on_type = true; - app.state.worktree_create = Some(crate::app::state::WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "generated-branch".into(), - checkout_path: "/repo/herdr-generated-branch".into(), - error: None, - creating: false, - }); - - app.handle_paste("feature/linear-302".into()).await; - - assert_eq!(app.state.name_input, "feature/linear-302"); - assert_eq!( - app.state - .worktree_create - .as_ref() - .map(|create| create.branch.as_str()), - Some("feature/linear-302") - ); - } - - #[test] - fn modal_paste_shortcut_matches_platform_primary_v() { - #[cfg(target_os = "macos")] - let modifiers = KeyModifiers::SUPER; - #[cfg(not(target_os = "macos"))] - let modifiers = KeyModifiers::CONTROL; - - assert!(is_modal_paste_shortcut(&KeyEvent::new( - KeyCode::Char('v'), - modifiers - ))); - assert!(is_modal_paste_shortcut(&KeyEvent::new( - KeyCode::Char('V'), - modifiers | KeyModifiers::SHIFT - ))); - assert!(!is_modal_paste_shortcut(&KeyEvent::new( - KeyCode::Char('v'), - KeyModifiers::ALT - ))); - } - - #[test] - fn modal_paste_target_is_active_only_for_text_inputs() { - let mut state = AppState::test_new(); - - state.mode = Mode::RenameTab; - assert!(modal_paste_target_active(&state)); - - state.mode = Mode::Navigator; - state.navigator.search_focused = false; - assert!(!modal_paste_target_active(&state)); - state.navigator.search_focused = true; - assert!(modal_paste_target_active(&state)); - - state.mode = Mode::KeybindHelp; - state.keybind_help.search_focused = false; - assert!(!modal_paste_target_active(&state)); - state.keybind_help.search_focused = true; - assert!(modal_paste_target_active(&state)); - - state.mode = Mode::ConfirmClose; - assert!(!modal_paste_target_active(&state)); - } -} diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs deleted file mode 100644 index 2be89976..00000000 --- a/src/app/input/modal.rs +++ /dev/null @@ -1,2394 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -#[cfg(test)] -use ratatui::layout::Direction; -use ratatui::layout::Rect; - -use crate::{ - app::{ - state::{ - AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode, NavigatorStateFilter, - }, - App, - }, - input::TerminalKey, - layout::NavDirection, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ModalAction { - Continue, - Save, - Clear, - Cancel, - Confirm, - Apply, - Close, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ModalKeyBinding { - Enter, - Esc, - CtrlC, -} - -impl ModalKeyBinding { - fn matches(self, key: &KeyEvent) -> bool { - match self { - Self::Enter => key.code == KeyCode::Enter, - Self::Esc => key.code == KeyCode::Esc, - Self::CtrlC => { - key.code == KeyCode::Char('c') - && key.modifiers == crossterm::event::KeyModifiers::CONTROL - } - } - } -} - -#[derive(Debug, Clone, Copy)] -pub(super) struct ModalActionSpec { - pub action: A, - pub bindings: &'static [ModalKeyBinding], -} - -pub(super) fn modal_action_from_key( - key: &KeyEvent, - specs: &[ModalActionSpec], -) -> Option { - specs - .iter() - .find(|spec| spec.bindings.iter().any(|binding| binding.matches(key))) - .map(|spec| spec.action) -} - -pub(super) fn modal_action_from_buttons( - col: u16, - row: u16, - buttons: &[(Rect, A)], -) -> Option { - buttons.iter().find_map(|(rect, action)| { - (col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height) - .then_some(*action) - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum GlobalMenuAction { - Detach, - WhatsNew, - Keybinds, - ReloadConfig, - Settings, -} - -pub(super) fn global_menu_actions(state: &AppState) -> Vec { - let mut actions = vec![ - GlobalMenuAction::Settings, - GlobalMenuAction::Keybinds, - GlobalMenuAction::ReloadConfig, - ]; - if state.update_available.is_some() || state.latest_release_notes_available { - actions.push(GlobalMenuAction::WhatsNew); - } - actions.push(GlobalMenuAction::Detach); - actions -} - -pub(super) fn open_global_menu(state: &mut AppState) { - state.global_menu = MenuListState::new(0); - state.mode = Mode::GlobalMenu; -} - -pub(super) fn open_keybind_help(state: &mut AppState) { - state.keybind_help.scroll = 0; - state.keybind_help.query.clear(); - state.keybind_help.search_focused = false; - state.mode = Mode::KeybindHelp; -} - -fn open_update_release_notes(state: &mut AppState) { - let Some(notes) = state.latest_release_notes.as_ref() else { - return; - }; - - state.release_notes = Some(crate::app::state::ReleaseNotesState { - version: notes.version.clone(), - body: notes.body.clone(), - scroll: 0, - preview: notes.preview, - }); - state.mode = Mode::ReleaseNotes; -} - -pub(super) fn request_detach(state: &mut AppState) { - state.detach_requested = true; -} - -pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) { - match action { - GlobalMenuAction::Detach => { - leave_modal(state); - request_detach(state); - } - GlobalMenuAction::WhatsNew => open_update_release_notes(state), - GlobalMenuAction::Keybinds => open_keybind_help(state), - GlobalMenuAction::ReloadConfig => { - state.request_reload_config = true; - leave_modal(state); - } - GlobalMenuAction::Settings => super::settings::open_settings(state), - } -} - -pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { - let actions = global_menu_actions(state); - match key.code { - KeyCode::Esc => leave_modal(state), - KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(), - KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()), - KeyCode::Enter => { - if let Some(action) = actions.get(state.global_menu.highlighted).copied() { - apply_global_menu_action(state, action); - } - } - _ => {} - } -} - -pub(crate) fn handle_navigator_key( - state: &mut AppState, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - key: KeyEvent, -) { - if state.navigator.search_focused { - match key.code { - KeyCode::Esc => { - state.navigator.search_focused = false; - } - KeyCode::Enter => { - state.accept_navigator_selection_from(terminal_runtimes); - } - KeyCode::Backspace => { - state.navigator.state_filter = None; - state.navigator.query.pop(); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Up => state.move_navigator_selection_from(terminal_runtimes, -1), - KeyCode::Down => state.move_navigator_selection_from(terminal_runtimes, 1), - KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => { - state.move_navigator_selection_from(terminal_runtimes, 1) - } - KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => { - state.move_navigator_selection_from(terminal_runtimes, -1) - } - KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => { - state.navigator.query.clear(); - state.navigator.state_filter = None; - state.clamp_navigator_selection_from(terminal_runtimes); - } - KeyCode::Char(c) - if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => - { - insert_navigator_search_text(state, terminal_runtimes, &c.to_string()); - } - _ => {} - } - return; - } - - match key.code { - KeyCode::Esc => { - leave_modal(state); - } - KeyCode::Enter => { - state.accept_navigator_selection_from(terminal_runtimes); - } - KeyCode::Char('/') => { - state.navigator.state_filter = None; - state.navigator.search_focused = true; - state.clamp_navigator_selection_from(terminal_runtimes); - } - KeyCode::Backspace if state.navigator.state_filter.is_some() => { - state.navigator.state_filter = None; - state.clamp_navigator_selection_from(terminal_runtimes); - } - KeyCode::Char('a') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = None; - state.clamp_navigator_selection_from(terminal_runtimes); - } - KeyCode::Char('b') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = Some(NavigatorStateFilter::Blocked); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Char('w') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = Some(NavigatorStateFilter::Working); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Char('i') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = Some(NavigatorStateFilter::Idle); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Char('d') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = Some(NavigatorStateFilter::Done); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Char('j') | KeyCode::Down if key.modifiers.is_empty() => { - state.move_navigator_selection_from(terminal_runtimes, 1) - } - KeyCode::Char('k') | KeyCode::Up if key.modifiers.is_empty() => { - state.move_navigator_selection_from(terminal_runtimes, -1) - } - KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => state - .move_navigator_selection_by_lines_from( - terminal_runtimes, - (state.navigator_body_rect().height / 2).max(1) as isize, - ), - KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => state - .move_navigator_selection_by_lines_from( - terminal_runtimes, - -((state.navigator_body_rect().height / 2).max(1) as isize), - ), - KeyCode::Char(' ') => state.toggle_selected_navigator_workspace_from(terminal_runtimes), - KeyCode::Home => { - state.navigator.selected = 0; - state.ensure_navigator_selection_visible_from(terminal_runtimes); - } - KeyCode::End | KeyCode::Char('G') => { - state.navigator.selected = state - .navigator_rows_from(terminal_runtimes) - .len() - .saturating_sub(1); - state.ensure_navigator_selection_visible_from(terminal_runtimes); - } - _ => {} - } -} - -pub(crate) fn insert_navigator_search_text( - state: &mut AppState, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - text: &str, -) { - if !state.navigator.search_focused { - return; - } - state.navigator.state_filter = None; - state.navigator.query.push_str(text); - state.select_first_navigator_match_from(terminal_runtimes); -} - -pub(crate) fn insert_keybind_help_query_text(state: &mut AppState, text: &str) { - if !state.keybind_help.search_focused { - return; - } - state - .keybind_help - .query - .extend(text.chars().filter(|ch| !ch.is_control())); - state.keybind_help.scroll = 0; -} - -pub(super) fn keybind_help_back(state: &mut AppState) { - if state.keybind_help.search_focused { - state.keybind_help.query.clear(); - state.keybind_help.search_focused = false; - state.keybind_help.scroll = 0; - } else { - leave_modal(state); - } -} - -pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) { - if state.keybind_help.search_focused { - let text_char = crate::input::keybind_help_text_char(&key); - match key.code { - KeyCode::Up => state.scroll_keybind_help(-1), - KeyCode::Down => state.scroll_keybind_help(1), - KeyCode::PageUp => state.scroll_keybind_help(-8), - KeyCode::PageDown => state.scroll_keybind_help(8), - KeyCode::Home => state.keybind_help.scroll = 0, - KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), - KeyCode::Backspace => { - state.keybind_help.query.pop(); - state.keybind_help.scroll = 0; - } - KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => { - state.keybind_help.query.clear(); - state.keybind_help.scroll = 0; - } - KeyCode::Esc => keybind_help_back(state), - KeyCode::Enter => leave_modal(state), - _ => { - if let Some(character) = text_char { - insert_keybind_help_query_text(state, &character.to_string()); - } - } - } - return; - } - - match key.code { - KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1), - KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1), - KeyCode::PageUp => state.scroll_keybind_help(-8), - KeyCode::PageDown => state.scroll_keybind_help(8), - KeyCode::Home => state.keybind_help.scroll = 0, - KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), - _ if crate::input::keybind_help_text_char(&key) == Some('/') => { - state.keybind_help.search_focused = true; - state.keybind_help.scroll = 0; - } - KeyCode::Esc => keybind_help_back(state), - KeyCode::Enter => leave_modal(state), - _ if crate::input::keybind_help_text_char(&key) == Some('?') => leave_modal(state), - _ => {} - } -} - -pub(super) fn open_rename_workspace( - state: &mut AppState, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ws_idx: usize, -) { - state.pending_workspace_create_cwd = None; - state.selected = ws_idx; - state.rename_pane_target = None; - state.name_input = - state.workspaces[ws_idx].display_name_from(&state.terminals, terminal_runtimes); - state.name_input_replace_on_type = false; - state.mode = Mode::RenameWorkspace; -} - -pub(crate) fn open_new_workspace_dialog(state: &mut AppState, cwd: std::path::PathBuf) { - let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = Some(cwd); - state.rename_pane_target = None; - state.name_input = suggested_name; - state.name_input_replace_on_type = true; - state.mode = Mode::RenameWorkspace; -} - -pub(super) fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) { - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) { - if let Some(name) = ws.active_tab_display_name() { - state.name_input = name; - state.name_input_replace_on_type = replace_on_type; - state.mode = Mode::RenameTab; - } - } -} - -pub(super) fn open_rename_pane(state: &mut AppState, pane_id: crate::layout::PaneId) { - let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) else { - return; - }; - let Some(pane) = ws.pane_state(pane_id) else { - return; - }; - let terminal = state.terminals.get(&pane.attached_terminal_id); - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = Some(pane_id); - state.name_input = terminal - .and_then(|t| t.manual_label.clone()) - .unwrap_or_default(); - state.name_input_replace_on_type = terminal.and_then(|t| t.manual_label.as_ref()).is_none(); - state.mode = Mode::RenamePane; -} - -fn workspace_create_label(input: &str, suggested_name: &str) -> Option { - let name = input.trim(); - (!name.is_empty() && name != suggested_name).then(|| name.to_string()) -} - -fn next_new_tab_default_name(state: &AppState) -> String { - state - .active - .and_then(|i| state.workspaces.get(i)) - .map(|ws| (ws.tabs.len() + 1).to_string()) - .unwrap_or_else(|| "1".to_string()) -} - -pub(super) fn open_new_tab_dialog(state: &mut AppState) { - state.creating_new_tab = true; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - state.name_input = next_new_tab_default_name(state); - state.name_input_replace_on_type = true; - state.mode = Mode::RenameTab; -} - -pub(super) fn leave_modal(state: &mut AppState) { - if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - } -} - -pub(super) const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec] = &[ModalActionSpec { - action: ModalAction::Continue, - bindings: &[ModalKeyBinding::Enter], -}]; - -pub(super) const RELEASE_NOTES_ACTIONS: &[ModalActionSpec] = &[ModalActionSpec { - action: ModalAction::Close, - bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::Esc], -}]; - -pub(super) const RENAME_ACTIONS: &[ModalActionSpec] = &[ - ModalActionSpec { - action: ModalAction::Save, - bindings: &[ModalKeyBinding::Enter], - }, - ModalActionSpec { - action: ModalAction::Clear, - bindings: &[ModalKeyBinding::CtrlC], - }, - ModalActionSpec { - action: ModalAction::Cancel, - bindings: &[ModalKeyBinding::Esc], - }, -]; - -pub(super) const CONFIRM_CLOSE_ACTIONS: &[ModalActionSpec] = &[ - ModalActionSpec { - action: ModalAction::Confirm, - bindings: &[ModalKeyBinding::Enter], - }, - ModalActionSpec { - action: ModalAction::Cancel, - bindings: &[ModalKeyBinding::Esc], - }, -]; - -pub(super) const SETTINGS_ACTIONS: &[ModalActionSpec] = &[ - ModalActionSpec { - action: ModalAction::Apply, - bindings: &[ModalKeyBinding::Enter], - }, - ModalActionSpec { - action: ModalAction::Close, - bindings: &[ModalKeyBinding::Esc], - }, -]; - -#[cfg(test)] -pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { - match action { - ModalAction::Save => { - let new_name = if state.name_input.trim().is_empty() { - state.name_input.clone() - } else { - state.name_input.trim().to_string() - }; - match state.mode { - Mode::RenameWorkspace - if state.pending_workspace_create_cwd.is_none() - && !state.workspaces.is_empty() - && !new_name.is_empty() => - { - let workspace_id = state.workspaces[state.selected].id.clone(); - state.workspaces[state.selected].set_custom_name(new_name); - crate::logging::workspace_renamed(&workspace_id); - state.mark_session_dirty(); - } - Mode::RenameTab if state.creating_new_tab => { - state.request_new_tab = true; - let default_name = next_new_tab_default_name(state); - state.requested_new_tab_name = - if new_name.is_empty() || new_name == default_name { - None - } else { - Some(new_name) - }; - } - Mode::RenameTab => { - if let Some(ws_idx) = state.active { - if let Some(ws) = state.workspaces.get_mut(ws_idx) { - let workspace_id = ws.id.clone(); - let active_tab = ws.active_tab; - let keep_auto_name = ws - .tabs - .get(active_tab) - .is_some_and(|tab| tab.is_auto_named()) - && ws - .tab_display_name(active_tab) - .is_some_and(|name| new_name == name); - if let Some(tab) = ws.active_tab_mut() { - if !new_name.is_empty() && !keep_auto_name { - tab.set_custom_name(new_name); - let tab_id = ws - .public_tab_number(active_tab) - .map(|number| { - crate::workspace::public_tab_id_for_number( - &workspace_id, - number, - ) - }) - .unwrap_or_else(|| workspace_id.clone()); - crate::logging::tab_renamed(&workspace_id, &tab_id); - state.mark_session_dirty(); - } - } - } - } - } - Mode::RenamePane => { - if let (Some(ws_idx), Some(pane_id)) = (state.active, state.rename_pane_target) - { - if let Some(ws) = state.workspaces.get(ws_idx) { - if let Some(pane) = ws.pane_state(pane_id) { - let terminal_id = pane.attached_terminal_id.clone(); - if let Some(terminal) = state.terminals.get_mut(&terminal_id) { - terminal.set_manual_label(new_name); - state.mark_session_dirty(); - } - } - } - } - } - _ => {} - } - state.creating_new_tab = false; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - state.name_input.clear(); - state.name_input_replace_on_type = false; - leave_modal(state); - } - ModalAction::Clear => { - state.name_input.clear(); - state.name_input_replace_on_type = false; - } - ModalAction::Cancel => { - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - state.name_input.clear(); - state.name_input_replace_on_type = false; - leave_modal(state); - } - _ => {} - } -} - -fn clear_rename_input(state: &mut AppState) { - state.name_input.clear(); - state.name_input_replace_on_type = false; -} - -pub(crate) fn insert_rename_input_text(state: &mut AppState, text: &str) { - if state.name_input_replace_on_type { - clear_rename_input(state); - } - state.name_input.push_str(text); -} - -fn delete_rename_input_char(state: &mut AppState) { - if state.name_input_replace_on_type { - clear_rename_input(state); - } else { - state.name_input.pop(); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RenameWordDeleteClass { - Word, - Separator, -} - -fn rename_word_delete_class(ch: char) -> RenameWordDeleteClass { - if ch.is_alphanumeric() || ch == '_' { - RenameWordDeleteClass::Word - } else { - RenameWordDeleteClass::Separator - } -} - -fn delete_rename_input_word(state: &mut AppState) { - if state.name_input_replace_on_type { - clear_rename_input(state); - return; - } - - while state - .name_input - .chars() - .last() - .is_some_and(char::is_whitespace) - { - state.name_input.pop(); - } - - let Some(class) = state - .name_input - .chars() - .last() - .map(rename_word_delete_class) - else { - return; - }; - - while state - .name_input - .chars() - .last() - .is_some_and(|ch| !ch.is_whitespace() && rename_word_delete_class(ch) == class) - { - state.name_input.pop(); - } -} - -fn handle_rename_edit_key(state: &mut AppState, key: KeyEvent) { - match key.code { - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - clear_rename_input(state); - } - KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => { - clear_rename_input(state); - } - KeyCode::Backspace - if key.modifiers.contains(KeyModifiers::CONTROL) - || key.modifiers.contains(KeyModifiers::ALT) => - { - delete_rename_input_word(state); - } - KeyCode::Char('h' | 'w') if key.modifiers.contains(KeyModifiers::CONTROL) => { - delete_rename_input_word(state); - } - KeyCode::Backspace => delete_rename_input_char(state), - KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { - insert_rename_input_text(state, &c.to_string()); - } - _ => {} - } -} - -#[cfg(test)] -pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) { - if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) { - apply_rename_action(state, action); - return; - } - - handle_rename_edit_key(state, key); -} - -#[cfg(test)] -pub(crate) fn handle_resize_key(state: &mut AppState, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - if key.code == KeyCode::Esc - || key.code == KeyCode::Enter - || state.keybinds.resize_mode.matches_prefix_key(&raw_key) - || state.keybinds.resize_mode.matches_direct_key(&raw_key) - { - if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - } - return; - } - - match key.code { - KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left), - KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right), - KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down), - KeyCode::Char('k') | KeyCode::Up => state.resize_pane(NavDirection::Up), - _ => {} - } -} - -pub(super) fn open_confirm_close(state: &mut AppState) { - state.begin_workspace_close_confirmation(state.selected); -} - -#[cfg(test)] -pub(super) fn confirm_close_accept(state: &mut AppState) { - if let Some(ws_idx) = state.take_confirmed_workspace_close_index() { - state.selected = ws_idx; - state.close_selected_workspace(); - } - if state.workspaces.is_empty() { - state.mode = Mode::Navigate; - } else { - state.mode = Mode::Terminal; - } -} - -pub(super) fn confirm_close_cancel(state: &mut AppState) { - state.confirm_close_workspace_id = None; - state.mode = Mode::Navigate; -} - -#[cfg(test)] -pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) { - match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) { - Some(ModalAction::Confirm) => confirm_close_accept(state), - Some(ModalAction::Cancel) => confirm_close_cancel(state), - _ => {} - } -} - -#[cfg(test)] -pub(super) fn apply_context_menu_action( - state: &mut AppState, - terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, - menu: ContextMenuState, - idx: usize, -) { - let item = menu.items().get(idx).copied(); - match (menu.kind, item) { - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("New worktree")) => { - state.request_new_linked_worktree = Some(ws_idx); - leave_modal(state); - } - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Delete worktree checkout...")) => { - state.request_remove_linked_worktree = Some(ws_idx); - leave_modal(state); - } - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Open worktree...")) => { - state.request_open_existing_worktree = Some(ws_idx); - leave_modal(state); - } - ( - ContextMenuKind::GitWorkspace { - ws_idx, collapsed, .. - }, - Some("Collapse" | "Expand"), - ) => { - if let Some(key) = state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.clone()) - { - if collapsed { - state.collapsed_space_keys.remove(&key); - } else { - state.collapsed_space_keys.insert(key); - } - state.mark_session_dirty(); - } - leave_modal(state); - } - ( - ContextMenuKind::Workspace { ws_idx } | ContextMenuKind::GitWorkspace { ws_idx, .. }, - Some("Rename"), - ) => { - open_rename_workspace(state, terminal_runtimes, ws_idx); - } - ( - ContextMenuKind::Workspace { ws_idx } | ContextMenuKind::GitWorkspace { ws_idx, .. }, - Some("Close" | "Close group"), - ) => { - state.selected = ws_idx; - if state.confirm_close { - open_confirm_close(state); - } else { - state.close_selected_workspace(); - state.mode = Mode::Navigate; - } - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - open_new_tab_dialog(state); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - open_rename_active_tab(state, false); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - if !state.close_tab() { - state.mode = if state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => { - open_rename_pane(state, pane_id); - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Clear pane name"), - ) => { - if let Some(ws) = state.workspaces.get(ws_idx) { - if let Some(pane) = ws.pane_state(pane_id) { - let terminal_id = pane.attached_terminal_id.clone(); - if let Some(terminal) = state.terminals.get_mut(&terminal_id) { - terminal.clear_manual_label(); - state.mark_session_dirty(); - } - } - } - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - source_pane_id, - .. - }, - Some("Swap with focused pane"), - ) => { - if let Some(source_pane_id) = source_pane_id { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - if let Some(tab) = state - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) - { - if tab.layout.swap_panes(source_pane_id, pane_id) { - tab.layout.focus_pane(source_pane_id); - state.mark_session_dirty(); - } - } - } - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - .. - }, - Some("Split right"), - ) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.focus_pane_in_workspace(ws_idx, pane_id); - state.split_pane(terminal_runtimes, Direction::Horizontal); - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - .. - }, - Some("Split down"), - ) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.focus_pane_in_workspace(ws_idx, pane_id); - state.split_pane(terminal_runtimes, Direction::Vertical); - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - .. - }, - Some("Zoom"), - ) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.focus_pane_in_workspace(ws_idx, pane_id); - state.toggle_zoom(); - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - .. - }, - Some("Close pane"), - ) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.focus_pane_in_workspace(ws_idx, pane_id); - if !state.close_pane() { - state.mode = if state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - _ => leave_modal(state), - } -} - -#[cfg(test)] -pub(crate) fn handle_context_menu_key( - state: &mut AppState, - terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, - key: KeyEvent, -) { - match key.code { - KeyCode::Esc => { - state.context_menu = None; - leave_modal(state); - } - KeyCode::Up => { - if let Some(menu) = &mut state.context_menu { - menu.list.move_prev(); - } - } - KeyCode::Down => { - if let Some(menu) = &mut state.context_menu { - menu.list.move_next(menu.items().len()); - } - } - KeyCode::Enter => { - if let Some(menu) = state.context_menu.take() { - let idx = menu.list.highlighted; - apply_context_menu_action(state, terminal_runtimes, menu, idx); - } - } - _ => {} - } -} - -impl App { - pub(crate) fn handle_rename_key_via_api(&mut self, key: KeyEvent) { - if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) { - self.apply_rename_mouse_action_via_api(action); - return; - } - - handle_rename_edit_key(&mut self.state, key); - } - - fn save_rename_modal_via_api(&mut self) { - let new_name = if self.state.name_input.trim().is_empty() { - self.state.name_input.clone() - } else { - self.state.name_input.trim().to_string() - }; - - match self.state.mode { - Mode::RenameWorkspace => { - if let Some(cwd) = self.state.pending_workspace_create_cwd.take() { - let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); - let label = workspace_create_label(&new_name, &suggested_name); - self.runtime_workspace_create( - "tui.workspace.create_named", - crate::api::schema::WorkspaceCreateParams { - source_workspace_id: None, - cwd: Some(cwd.display().to_string()), - focus: true, - label, - env: Default::default(), - }, - ); - } else if !self.state.workspaces.is_empty() && !new_name.is_empty() { - let workspace_id = self.public_workspace_id(self.state.selected); - self.runtime_workspace_rename( - "tui.workspace.rename", - crate::api::schema::WorkspaceRenameParams { - workspace_id, - label: new_name, - }, - ); - } - } - Mode::RenameTab if self.state.creating_new_tab => { - let default_name = next_new_tab_default_name(&self.state); - let label = if new_name.is_empty() || new_name == default_name { - None - } else { - Some(new_name) - }; - self.runtime_tab_create( - "tui.tab.create_named", - crate::api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label, - env: Default::default(), - }, - ); - } - Mode::RenameTab if !new_name.is_empty() => { - let Some(ws_idx) = self.state.active else { - cancel_rename_modal(&mut self.state); - return; - }; - let tab_idx = self.state.workspaces[ws_idx].active_tab; - let keep_auto_name = self.state.workspaces[ws_idx] - .tabs - .get(tab_idx) - .is_some_and(|tab| tab.is_auto_named()) - && self.state.workspaces[ws_idx] - .tab_display_name(tab_idx) - .is_some_and(|name| new_name == name); - if !keep_auto_name { - if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { - self.runtime_tab_rename( - "tui.tab.rename", - crate::api::schema::TabRenameParams { - tab_id, - label: new_name, - }, - ); - } - } - } - Mode::RenamePane => { - if let (Some(ws_idx), Some(pane_id)) = - (self.state.active, self.state.rename_pane_target) - { - if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.runtime_pane_rename( - "tui.pane.rename", - crate::api::schema::PaneRenameParams { - pane_id, - label: Some(new_name), - }, - ); - } - } - } - _ => {} - } - - cancel_rename_modal(&mut self.state); - } - - pub(super) fn apply_rename_mouse_action_via_api(&mut self, action: ModalAction) { - match action { - ModalAction::Save => self.save_rename_modal_via_api(), - ModalAction::Clear => { - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - } - ModalAction::Cancel => cancel_rename_modal(&mut self.state), - _ => {} - } - } - - pub(super) fn confirm_close_accept_via_api(&mut self) { - if let Some(ws_idx) = self.state.take_confirmed_workspace_close_index() { - self.close_workspace_idx_with_group_via_api(ws_idx); - } - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - pub(crate) fn handle_resize_key_via_api(&mut self, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - if key.code == KeyCode::Esc - || key.code == KeyCode::Enter - || self.state.keybinds.resize_mode.matches_prefix_key(&raw_key) - || self.state.keybinds.resize_mode.matches_direct_key(&raw_key) - { - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - return; - } - - let direction = match key.code { - KeyCode::Char('h') | KeyCode::Left => Some(NavDirection::Left), - KeyCode::Char('l') | KeyCode::Right => Some(NavDirection::Right), - KeyCode::Char('j') | KeyCode::Down => Some(NavDirection::Down), - KeyCode::Char('k') | KeyCode::Up => Some(NavDirection::Up), - _ => None, - }; - if let Some(direction) = direction { - self.runtime_pane_resize( - "tui.pane.resize", - crate::api::schema::PaneResizeParams { - pane_id: None, - direction: super::navigate::api_pane_direction(direction), - amount: None, - }, - ); - } - } - - pub(crate) fn handle_confirm_close_key_via_api(&mut self, key: KeyEvent) { - match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) { - Some(ModalAction::Confirm) => { - self.confirm_close_accept_via_api(); - } - Some(ModalAction::Cancel) => confirm_close_cancel(&mut self.state), - _ => {} - } - } - - pub(crate) fn handle_context_menu_key_via_api(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.context_menu = None; - leave_modal(&mut self.state); - } - KeyCode::Up => { - if let Some(menu) = &mut self.state.context_menu { - menu.list.move_prev(); - } - } - KeyCode::Down => { - if let Some(menu) = &mut self.state.context_menu { - menu.list.move_next(menu.items().len()); - } - } - KeyCode::Enter => { - if let Some(menu) = self.state.context_menu.take() { - let idx = menu.list.highlighted; - self.apply_context_menu_action_via_api(menu, idx); - } - } - _ => {} - } - } - - pub(crate) fn apply_context_menu_action_via_api(&mut self, menu: ContextMenuState, idx: usize) { - let item = menu.items().get(idx).copied(); - match (menu.kind, item) { - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("New worktree")) => { - self.state.request_new_linked_worktree = Some(ws_idx); - leave_modal(&mut self.state); - } - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Delete worktree checkout...")) => { - self.state.request_remove_linked_worktree = Some(ws_idx); - leave_modal(&mut self.state); - } - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Open worktree...")) => { - self.state.request_open_existing_worktree = Some(ws_idx); - leave_modal(&mut self.state); - } - ( - ContextMenuKind::GitWorkspace { - ws_idx, collapsed, .. - }, - Some("Collapse" | "Expand"), - ) => { - if let Some(key) = self - .state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.clone()) - { - if collapsed { - self.state.collapsed_space_keys.remove(&key); - } else { - self.state.collapsed_space_keys.insert(key); - } - self.state.mark_session_dirty(); - } - leave_modal(&mut self.state); - } - ( - ContextMenuKind::Workspace { ws_idx } - | ContextMenuKind::GitWorkspace { ws_idx, .. }, - Some("Rename"), - ) => open_rename_workspace(&mut self.state, &self.terminal_runtimes, ws_idx), - ( - ContextMenuKind::Workspace { ws_idx } - | ContextMenuKind::GitWorkspace { ws_idx, .. }, - Some("Close" | "Close group"), - ) => { - self.state.selected = ws_idx; - if self.state.confirm_close { - open_confirm_close(&mut self.state); - } else { - self.close_workspace_idx_with_group_via_api(ws_idx); - self.state.mode = Mode::Navigate; - } - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { - self.focus_workspace_idx_via_api(ws_idx); - self.focus_tab_idx_via_api(tab_idx); - open_new_tab_dialog(&mut self.state); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { - self.focus_workspace_idx_via_api(ws_idx); - self.focus_tab_idx_via_api(tab_idx); - open_rename_active_tab(&mut self.state, false); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { - self.focus_workspace_idx_via_api(ws_idx); - self.focus_tab_idx_via_api(tab_idx); - if !self.close_active_tab_via_api_requires_confirmation() { - leave_modal(&mut self.state); - } - } - (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => { - open_rename_pane(&mut self.state, pane_id); - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Clear pane name"), - ) => { - if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.runtime_pane_rename( - "tui.pane.clear_name", - crate::api::schema::PaneRenameParams { - pane_id, - label: None, - }, - ); - } - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some(action @ ("Send right-clicks to pane" | "Use Herdr right-click menu")), - ) => { - if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.runtime_pane_input_set( - "tui.pane.input.set", - crate::api::schema::PaneInputSetParams { - pane_id, - right_click: if action == "Send right-clicks to pane" { - crate::api::schema::PaneRightClickTarget::Pane - } else { - crate::api::schema::PaneRightClickTarget::Herdr - }, - }, - ); - } - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - pane_id, - source_pane_id: Some(source_pane_id), - .. - }, - Some("Swap with focused pane"), - ) => { - let source_public_id = self.public_pane_id(ws_idx, source_pane_id); - let target_public_id = self.public_pane_id(ws_idx, pane_id); - if let (Some(source_public_id), Some(target_public_id)) = - (source_public_id, target_public_id) - { - self.runtime_pane_swap( - "tui.pane.swap_exact", - crate::api::schema::PaneSwapParams { - pane_id: None, - direction: None, - source_pane_id: Some(source_public_id), - target_pane_id: Some(target_public_id), - }, - ); - self.focus_pane_internal_via_api(ws_idx, source_pane_id); - } - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Split right"), - ) => { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Right); - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Split down"), - ) => { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Down); - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Zoom"), - ) => { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.zoom_focused_pane_via_api(); - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Close pane"), - ) => { - self.focus_pane_internal_via_api(ws_idx, pane_id); - if !self.close_focused_pane_via_api_requires_confirmation() { - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - _ => leave_modal(&mut self.state), - } - } -} - -fn cancel_rename_modal(state: &mut AppState) { - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - state.name_input.clear(); - state.name_input_replace_on_type = false; - leave_modal(state); -} - -impl AppState { - pub(super) fn global_menu_item_at(&self, col: u16, row: u16) -> Option { - let rect = self.global_menu_rect(); - if col <= rect.x - || col >= rect.x + rect.width.saturating_sub(1) - || row <= rect.y - || row >= rect.y + rect.height.saturating_sub(1) - { - return None; - } - let idx = (row - rect.y - 1) as usize; - global_menu_actions(self).get(idx).copied() - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - use ratatui::layout::Rect; - - use super::super::{capture_snapshot, state_with_workspaces}; - use super::*; - use crate::workspace::Workspace; - - fn config_env_lock() -> &'static std::sync::Mutex<()> { - crate::config::test_config_env_lock() - } - - fn temp_config_path(name: &str) -> std::path::PathBuf { - let unique = format!( - "herdr-modal-{name}-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - std::env::temp_dir().join(unique).join("config.toml") - } - - fn app_with_test_workspaces(names: &[&str]) -> App { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &crate::config::Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); - app.state.ensure_test_terminals(); - app.state.active = (!app.state.workspaces.is_empty()).then_some(0); - app.state.selected = 0; - app - } - - #[test] - fn workspace_create_label_preserves_auto_name_for_suggestion_or_blank() { - assert_eq!(workspace_create_label("project", "project"), None); - assert_eq!(workspace_create_label("", "project"), None); - assert_eq!(workspace_create_label(" ", "project"), None); - assert_eq!( - workspace_create_label(" logs ", "project").as_deref(), - Some("logs") - ); - } - - fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) { - state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/worktree-{ws_idx}").into(), - is_linked_worktree: ws_idx != 0, - }); - } - - #[test] - fn custom_resize_key_exits_resize_mode() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::Resize; - state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g"); - - handle_resize_key( - &mut state, - TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn direct_resize_key_exits_resize_mode() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::Resize; - state.keybinds.resize_mode = crate::config::ActionKeybinds::direct("ctrl+alt+r"); - - handle_resize_key( - &mut state, - TerminalKey::new( - KeyCode::Char('r'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - ), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn resize_key_exit_matches_enhanced_shifted_punctuation() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::Resize; - state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("?"); - - handle_resize_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn detach_requests_client_detach() { - let mut state = state_with_workspaces(&["test"]); - - request_detach(&mut state); - - assert!(state.detach_requested); - assert!(!state.should_quit); - } - - #[test] - fn global_menu_whats_new_opens_saved_release_notes() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("whats-new-saved-release-notes"); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - crate::release_notes::save_pending(env!("CARGO_PKG_VERSION"), "### Changed\n- Menu") - .unwrap(); - - let mut state = state_with_workspaces(&["test"]); - state.latest_release_notes_available = true; - state.latest_release_notes = crate::release_notes::load_latest(); - - assert!(global_menu_actions(&state).contains(&GlobalMenuAction::WhatsNew)); - - apply_global_menu_action(&mut state, GlobalMenuAction::WhatsNew); - - assert_eq!(state.mode, Mode::ReleaseNotes); - assert_eq!( - state - .release_notes - .as_ref() - .map(|notes| notes.body.as_str()), - Some("### Changed\n- Menu") - ); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn rename_modal_keyboard_and_mouse_share_actions() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameWorkspace; - state.name_input = "hello".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - assert!(state.name_input.is_empty()); - - state.name_input = "renamed".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::Terminal); - assert_eq!(state.workspaces[0].display_name(), "renamed"); - let snapshot = capture_snapshot(&state); - assert_eq!( - snapshot.workspaces[0].custom_name.as_deref(), - Some("renamed") - ); - - state.view.sidebar_rect = Rect::new(0, 0, 26, 20); - state.view.terminal_area = Rect::new(26, 0, 80, 20); - state.mode = Mode::RenameWorkspace; - state.name_input = "mouse".into(); - let inner = state.rename_modal_inner().unwrap(); - let (save, _, _) = crate::ui::rename_button_rects(inner); - let action = modal_action_from_buttons(save.x, save.y, &[(save, ModalAction::Save)]); - assert_eq!(action, Some(ModalAction::Save)); - } - - #[test] - fn tab_rename_updates_captured_snapshot() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "logs".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - let snapshot = capture_snapshot(&state); - assert_eq!( - snapshot.workspaces[0].tabs[0].custom_name.as_deref(), - Some("logs") - ); - } - - #[test] - fn rename_cancel_returns_to_terminal_when_workspace_is_active() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "test".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(state.name_input.is_empty()); - } - - #[test] - fn rename_modal_replaces_prefilled_text_on_first_type() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "2".into(); - state.name_input_replace_on_type = true; - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()), - ); - assert_eq!(state.name_input, "n"); - assert!(!state.name_input_replace_on_type); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()), - ); - assert_eq!(state.name_input, "ne"); - } - - #[test] - fn rename_modal_replaces_prefilled_text_on_paste() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "2".into(); - state.name_input_replace_on_type = true; - - insert_rename_input_text(&mut state, "feature/logs"); - - assert_eq!(state.name_input, "feature/logs"); - assert!(!state.name_input_replace_on_type); - - insert_rename_input_text(&mut state, "-copy"); - - assert_eq!(state.name_input, "feature/logs-copy"); - } - - #[test] - fn rename_modal_handles_line_editing_shortcuts() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameWorkspace; - state.name_input = "website zero".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()), - ); - assert_eq!(state.name_input, "website zer"); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL), - ); - assert_eq!(state.name_input, "website "); - - state.name_input = "website-zero".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), - ); - assert_eq!(state.name_input, "website-"); - - state.name_input = "website-zero".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), - ); - assert_eq!(state.name_input, "website-"); - - state.name_input = "website-zero".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), - ); - assert_eq!(state.name_input, "website-"); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER), - ); - assert!(state.name_input.is_empty()); - - state.name_input = "website zero".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), - ); - assert!(state.name_input.is_empty()); - } - - #[test] - fn rename_modal_does_not_insert_modified_shortcut_chars() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameWorkspace; - state.name_input = "website".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), - ); - assert_eq!(state.name_input, "website"); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::SHIFT), - ); - assert_eq!(state.name_input, "websiteZ"); - } - - #[test] - fn keybind_help_slash_focuses_filter_and_preserves_vim_scroll() { - let mut state = state_with_workspaces(&["test"]); - state.keybind_help.query = "stale".into(); - state.keybind_help.search_focused = true; - state.view.terminal_area = Rect::new(0, 0, 100, 30); - - open_keybind_help(&mut state); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - assert_eq!(state.keybind_help.scroll, 1); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty()), - ); - assert_eq!(state.keybind_help.scroll, 0); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty()), - ); - assert!(state.keybind_help.query.is_empty()); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), - ); - for character in "work".chars() { - state.keybind_help.scroll = 2; - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char(character), KeyModifiers::empty()), - ); - } - - assert!(state.keybind_help.search_focused); - assert_eq!(state.keybind_help.query, "work"); - assert_eq!(state.keybind_help.scroll, 0); - } - - #[test] - fn keybind_help_query_supports_backspace_clear_and_sanitized_paste() { - let mut state = state_with_workspaces(&["test"]); - open_keybind_help(&mut state); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), - ); - - insert_keybind_help_query_text(&mut state, "work\nspace"); - assert_eq!(state.keybind_help.query, "workspace"); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Backspace, KeyModifiers::empty()), - ); - assert_eq!(state.keybind_help.query, "workspac"); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('u'), KeyModifiers::CONTROL), - ); - assert!(state.keybind_help.query.is_empty()); - } - - #[test] - fn keybind_help_escape_leaves_search_before_closing() { - let mut state = state_with_workspaces(&["test"]); - open_keybind_help(&mut state); - state.keybind_help.search_focused = true; - state.keybind_help.query = "work".into(); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::KeybindHelp); - assert!(!state.keybind_help.search_focused); - assert!(state.keybind_help.query.is_empty()); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn enhanced_shifted_slash_focuses_keybind_help_filter() { - let mut state = state_with_workspaces(&["test"]); - open_keybind_help(&mut state); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('7'), KeyModifiers::SHIFT) - .with_shifted_codepoint('/' as u32), - ); - - assert!(state.keybind_help.search_focused); - } - - #[test] - fn enhanced_shifted_question_mark_closes_keybind_help_when_not_searching() { - let mut state = state_with_workspaces(&["test"]); - open_keybind_help(&mut state); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!(state.mode, Mode::Terminal); - - open_keybind_help(&mut state); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), - ); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!(state.keybind_help.query, "?"); - } - - #[test] - fn navigator_search_accepts_pasted_text_when_focused() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.search_focused = true; - state.navigator.state_filter = Some(NavigatorStateFilter::Working); - - insert_navigator_search_text(&mut state, &terminal_runtimes, "beta"); - - assert_eq!(state.navigator.query, "beta"); - assert_eq!(state.navigator.state_filter, None); - } - - #[test] - fn navigator_search_ignores_paste_when_search_is_not_focused() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.search_focused = false; - - insert_navigator_search_text(&mut state, &terminal_runtimes, "beta"); - - assert!(state.navigator.query.is_empty()); - } - - #[test] - fn navigator_empty_search_escape_returns_to_commands() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.search_focused = true; - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - assert!(!state.navigator.search_focused); - assert!(state.navigator.query.is_empty()); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('w'), KeyModifiers::empty()), - ); - - assert_eq!( - state.navigator.state_filter, - Some(NavigatorStateFilter::Working) - ); - assert!(state.navigator.query.is_empty()); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn navigator_search_escape_blurs_then_next_escape_closes() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.search_focused = true; - state.navigator.query = "a".into(); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - assert!(!state.navigator.search_focused); - assert_eq!(state.navigator.query, "a"); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - - assert_eq!(state.navigator.selected, 1); - assert_eq!(state.navigator.query, "a"); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('/'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - assert!(state.navigator.search_focused); - assert_eq!(state.navigator.query, "a"); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('l'), KeyModifiers::empty()), - ); - - assert_eq!(state.navigator.query, "al"); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - assert!(!state.navigator.search_focused); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn navigator_ignores_modified_j_and_k() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.selected = 1; - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), - ); - - assert_eq!(state.navigator.selected, 1); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), - ); - - assert_eq!(state.navigator.selected, 1); - } - - #[test] - fn open_rename_active_tab_can_prefill_default_new_tab_name() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_add_tab(None); - state.workspaces[0].switch_tab(1); - - open_rename_active_tab(&mut state, true); - - assert_eq!(state.mode, Mode::RenameTab); - assert_eq!(state.name_input, "2"); - assert!(state.name_input_replace_on_type); - } - - #[test] - fn cancel_new_tab_dialog_leaves_workspace_unchanged() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(!state.request_new_tab); - assert!(state.requested_new_tab_name.is_none()); - assert_eq!(state.workspaces[0].tabs.len(), 1); - } - - #[test] - fn saving_new_tab_dialog_requests_creation_with_name() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - state.name_input = "logs".into(); - state.name_input_replace_on_type = false; - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(state.request_new_tab); - assert_eq!(state.requested_new_tab_name.as_deref(), Some("logs")); - } - - #[test] - fn saving_new_tab_dialog_with_default_name_keeps_tab_auto_named() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(state.request_new_tab); - assert!(state.requested_new_tab_name.is_none()); - } - - #[test] - fn closing_first_auto_tab_compacts_remaining_auto_tab_label_and_next_prompt() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - state.workspaces[0].test_add_tab(state.requested_new_tab_name.as_deref()); - state.request_new_tab = false; - state.requested_new_tab_name = None; - - state.workspaces[0].close_tab(0); - state.workspaces[0].switch_tab(0); - - assert_eq!( - state.workspaces[0].tab_display_name(0).as_deref(), - Some("1") - ); - assert!(state.workspaces[0].tabs[0].custom_name.is_none()); - - open_new_tab_dialog(&mut state); - assert_eq!(state.name_input, "2"); - } - - #[test] - fn renaming_auto_tab_to_its_default_number_keeps_it_auto_named() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_add_tab(None); - state.workspaces[0].switch_tab(1); - - open_rename_active_tab(&mut state, false); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(state.workspaces[0].tabs[1].custom_name.is_none()); - assert_eq!( - state.workspaces[0].tab_display_name(1).as_deref(), - Some("2") - ); - } - - #[test] - fn confirm_close_keyboard_actions_are_direct_not_focused() { - let mut state = state_with_workspaces(&["a", "b"]); - state.selected = 1; - open_confirm_close(&mut state); - - handle_confirm_close_key( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::Navigate); - assert_eq!(state.workspaces.len(), 2); - - open_confirm_close(&mut state); - handle_confirm_close_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(state.workspaces.len(), 1); - } - - #[test] - fn confirm_close_for_linked_worktree_closes_workspace_only() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.selected = 1; - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - open_confirm_close(&mut state); - handle_confirm_close_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.request_remove_linked_worktree, None); - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].display_name(), "main"); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn context_menu_close_group_opens_group_close_confirmation() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.active = Some(0); - state.selected = 1; - state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }); - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - let menu = ContextMenuState { - kind: ContextMenuKind::GitWorkspace { - ws_idx: 0, - is_linked_worktree: false, - has_worktree_children: true, - collapsed: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - - apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, 1); - - assert_eq!(state.selected, 0); - assert_eq!(state.mode, Mode::ConfirmClose); - - confirm_close_accept(&mut state); - - assert!(state.workspaces.is_empty()); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn context_menu_toggles_pane_right_click_passthrough() { - let mut app = app_with_test_workspaces(&["main"]); - app.state.active = Some(0); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let menu = ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx: 0, - tab_idx: 0, - pane_id, - source_pane_id: None, - has_manual_label: false, - right_click_passthrough: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let idx = menu - .items() - .iter() - .position(|item| *item == "Send right-clicks to pane") - .unwrap(); - app.apply_context_menu_action_via_api(menu, idx); - - assert!( - app.state.workspaces[0] - .pane_state(pane_id) - .unwrap() - .right_click_passthrough - ); - } - - #[test] - fn context_menu_close_pane_last_parent_group_pane_keeps_confirmation_mode() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.active = Some(0); - state.selected = 1; - state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }); - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - let pane_id = state.workspaces[0].tabs[0].root_pane; - let menu = ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx: 0, - tab_idx: 0, - pane_id, - source_pane_id: None, - has_manual_label: false, - right_click_passthrough: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let idx = menu - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane item"); - let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - - apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, idx); - - assert_eq!(state.selected, 0); - assert_eq!(state.mode, Mode::ConfirmClose); - assert_eq!(state.workspaces.len(), 2); - } - - #[test] - fn api_confirm_close_accept_closes_parent_worktree_group() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.selected = 0; - open_confirm_close(&mut app.state); - - app.handle_confirm_close_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert!(app.state.workspaces.is_empty()); - assert_eq!(app.state.mode, Mode::Navigate); - assert_eq!(app.event_hub.events_after(0).len(), 2); - } - - #[test] - fn api_confirm_close_accept_keeps_the_original_workspace_target() { - let mut app = app_with_test_workspaces(&["main", "issue", "other"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.selected = 0; - open_confirm_close(&mut app.state); - - app.focus_workspace_idx_via_api(2); - assert_eq!(app.state.selected, 2); - assert_eq!(app.state.mode, Mode::ConfirmClose); - - app.handle_confirm_close_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "other"); - assert_eq!( - app.event_hub - .events_after(0) - .iter() - .filter(|(_, event)| matches!( - event.event, - crate::api::schema::EventKind::WorkspaceClosed - )) - .count(), - 2 - ); - } - - #[test] - fn api_context_menu_close_tab_last_parent_group_workspace_keeps_confirmation_mode() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::ContextMenu; - let menu = ContextMenuState { - kind: ContextMenuKind::Tab { - ws_idx: 0, - tab_idx: 0, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let idx = menu - .items() - .iter() - .position(|item| *item == "Close") - .expect("close tab item"); - - app.apply_context_menu_action_via_api(menu, idx); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - } - - #[test] - fn api_context_menu_enter_close_pane_last_parent_group_pane_keeps_confirmation_mode() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::ContextMenu; - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let mut menu = ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx: 0, - tab_idx: 0, - pane_id, - source_pane_id: None, - has_manual_label: false, - right_click_passthrough: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let close_idx = menu - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane item"); - menu.list.highlighted = close_idx; - app.state.context_menu = Some(menu); - - app.handle_context_menu_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - assert!(app.state.context_menu.is_none()); - } -} diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs deleted file mode 100644 index 5dda49f9..00000000 --- a/src/app/input/mouse.rs +++ /dev/null @@ -1,4774 +0,0 @@ -use bytes::Bytes; -use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; -use ratatui::layout::{Direction, Rect}; -use tracing::warn; - -use crate::{ - app::state::{ - AgentPanelSort, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, - MenuListState, Mode, RightClickPassthroughGesture, TabPressState, ViewLayout, - WorkspacePressState, - }, - layout::{PaneInfo, SplitBorder}, - selection::Selection, - terminal::TerminalRuntimeRegistry, -}; - -#[cfg(test)] -use super::WheelRouting; -use super::{ - modal::{ - apply_global_menu_action, confirm_close_cancel, global_menu_actions, leave_modal, - modal_action_from_buttons, open_global_menu, open_new_tab_dialog, ModalAction, - }, - settings::SettingsAction, - ScrollbarClickTarget, TAB_DRAG_THRESHOLD, WORKSPACE_DRAG_THRESHOLD, -}; - -pub(super) enum MouseAction { - NewWorkspace, - Settings(SettingsAction), - FocusWorkspace { - ws_idx: usize, - }, - FocusTab { - tab_idx: usize, - }, - FocusPane { - ws_idx: usize, - pane_id: crate::layout::PaneId, - }, - FocusToastTarget, - MoveWorkspace { - source_ws_idx: usize, - insert_idx: usize, - }, - MoveWorkspaceBlock { - params: crate::api::schema::WorkspaceMoveBlockParams, - }, - MoveTab { - ws_idx: usize, - source_tab_idx: usize, - insert_idx: usize, - }, - SetSplitRatio { - path: Vec, - ratio: f32, - }, - RenameModal(ModalAction), - ConfirmCloseAccept, - ContextMenu { - menu: ContextMenuState, - idx: usize, - }, -} - -enum MobileMouseResult { - Ignored, - Consumed, - Action(MouseAction), -} - -impl AppState { - pub(crate) fn handle_pane_mouse_only( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - mouse: MouseEvent, - ) { - if self.mode != Mode::Terminal { - return; - } - let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { - return; - }; - - match mouse.kind { - MouseEventKind::ScrollUp - | MouseEventKind::ScrollDown - | MouseEventKind::ScrollLeft - | MouseEventKind::ScrollRight => { - self.forward_pane_reported_wheel(terminal_runtimes, &info, mouse); - } - MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { - self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); - } - MouseEventKind::Moved => { - self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse); - } - } - } - - pub(super) fn handle_mouse( - &mut self, - terminal_runtimes: &mut TerminalRuntimeRegistry, - source_id: crate::app::InputSourceId, - mouse: MouseEvent, - ) -> Option { - if self.mode == Mode::Onboarding { - self.handle_onboarding_mouse(mouse); - return None; - } - - if self.mode == Mode::Terminal - && self.clickable_toast_at(mouse.column, mouse.row) - && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - { - return Some(MouseAction::FocusToastTarget); - } - - if self.mode == Mode::Terminal - && self.clickable_toast_at(mouse.column, mouse.row) - && matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) - { - return None; - } - - if self.mode == Mode::Settings { - return self.handle_settings_mouse(mouse).map(MouseAction::Settings); - } - - let launcher_enabled = self.view.layout != ViewLayout::Mobile - && !self.sidebar_collapsed - && matches!( - self.mode, - Mode::Terminal - | Mode::Navigate - | Mode::Resize - | Mode::GlobalMenu - | Mode::KeybindHelp - ); - let launcher = self.global_launcher_rect(); - let launcher_hit = launcher_enabled - && mouse.column >= launcher.x - && mouse.column < launcher.x + launcher.width - && mouse.row >= launcher.y - && mouse.row < launcher.y + launcher.height; - - if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu { - let actions = global_menu_actions(self); - let hovered = self - .global_menu_item_at(mouse.column, mouse.row) - .and_then(|action| actions.iter().position(|item| *item == action)); - self.global_menu.hover(hovered); - return None; - } - - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit { - if self.mode == Mode::GlobalMenu { - leave_modal(self); - } else { - open_global_menu(self); - } - return None; - } - - if self.mode == Mode::GlobalMenu { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) { - apply_global_menu_action(self, action); - } else { - leave_modal(self); - } - } - return None; - } - - if self.mode == Mode::KeybindHelp { - return None; - } - - if self.view.layout == ViewLayout::Mobile { - match self.handle_mobile_mouse(mouse) { - MobileMouseResult::Ignored => {} - MobileMouseResult::Consumed => return None, - MobileMouseResult::Action(action) => return Some(action), - } - } - - let sidebar = self.view.sidebar_rect; - let in_sidebar = mouse.column >= sidebar.x - && mouse.column < sidebar.x + sidebar.width - && mouse.row >= sidebar.y - && mouse.row < sidebar.y + sidebar.height; - - if self.handle_right_click_passthrough(terminal_runtimes, source_id, mouse, in_sidebar) { - return None; - } - - if self.mode == Mode::OpenExistingWorktree { - match mouse.kind { - MouseEventKind::ScrollUp => { - if let Some(open) = &mut self.worktree_open { - open.select_previous_filtered(); - } - return None; - } - MouseEventKind::ScrollDown => { - if let Some(open) = &mut self.worktree_open { - open.select_next_filtered(); - } - return None; - } - _ => {} - } - } - - if matches!( - self.mode, - Mode::NewLinkedWorktree | Mode::OpenExistingWorktree | Mode::ConfirmRemoveWorktree - ) && !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - { - return None; - } - - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - self.selection = None; - self.selection_autoscroll = None; - self.clear_chrome_press(source_id); - - if self.mode == Mode::ConfirmClose { - let popup = self.confirm_close_rect(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (confirm, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => { - return Some(MouseAction::ConfirmCloseAccept); - } - Some(ModalAction::Cancel) | None => confirm_close_cancel(self), - _ => {} - } - return None; - } - - if self.mode == Mode::NewLinkedWorktree { - if let Some(inner) = - crate::ui::new_linked_worktree_inner_rect(self.screen_rect()) - { - let (create, cancel) = crate::ui::new_linked_worktree_button_rects(inner); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (create, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => { - self.request_submit_worktree_create = true; - } - Some(ModalAction::Cancel) - if !self - .worktree_create - .as_ref() - .is_some_and(|create| create.creating) => - { - self.worktree_create = None; - self.name_input.clear(); - self.name_input_replace_on_type = false; - leave_modal(self); - } - _ => {} - } - } - return None; - } - - if self.mode == Mode::OpenExistingWorktree { - if let Some(open) = self.worktree_open.as_ref() { - if let Some(inner) = crate::ui::open_existing_worktree_inner_rect( - self.screen_rect(), - open.entries.len(), - ) { - let filtered = open.filtered_indices(); - let max_rows = - crate::ui::open_existing_worktree_max_visible_rows(inner); - let start = - crate::ui::open_existing_worktree_visible_start(open, max_rows); - if mouse.row == inner.y.saturating_add(1) - && mouse.column >= inner.x - && mouse.column < inner.x.saturating_add(inner.width) - { - if let Some(open) = &mut self.worktree_open { - open.search_focused = true; - } - return None; - } - let row_idx = if rect_contains(inner, mouse.column, mouse.row) { - mouse - .row - .checked_sub(inner.y.saturating_add(3)) - .map(usize::from) - .map(|row| row / 2) - .filter(|row| *row < max_rows) - .and_then(|row| filtered.get(start + row).copied()) - } else { - None - }; - if let Some(entry_idx) = row_idx { - if let Some(open) = &mut self.worktree_open { - open.selected = entry_idx; - } - self.request_submit_worktree_open = true; - return None; - } - - let (open_button, cancel) = - crate::ui::open_existing_worktree_button_rects(inner); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (open_button, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => { - self.request_submit_worktree_open = true; - } - Some(ModalAction::Cancel) => { - self.worktree_open = None; - leave_modal(self); - } - _ => {} - } - } - } - return None; - } - - if self.mode == Mode::ConfirmRemoveWorktree { - if let Some(popup) = crate::ui::remove_worktree_popup_rect(self.screen_rect()) { - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let force_confirmation = self - .worktree_remove - .as_ref() - .is_some_and(|remove| remove.force_confirmation); - let (remove, cancel) = - crate::ui::remove_worktree_button_rects(inner, force_confirmation); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (remove, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => { - self.request_submit_worktree_remove = true; - } - Some(ModalAction::Cancel) - if !self - .worktree_remove - .as_ref() - .is_some_and(|remove| remove.removing) => - { - self.worktree_remove = None; - leave_modal(self); - } - _ => {} - } - } - return None; - } - - if matches!( - self.mode, - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane - ) { - let action = self - .rename_modal_inner() - .map(crate::ui::rename_button_rects) - .and_then(|(save, clear, cancel)| { - modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (save, ModalAction::Save), - (clear, ModalAction::Clear), - (cancel, ModalAction::Cancel), - ], - ) - }) - .unwrap_or(ModalAction::Cancel); - return Some(MouseAction::RenameModal(action)); - } - - if self.mode == Mode::ContextMenu { - let item_idx = self.context_menu_item_at(mouse.column, mouse.row); - if let Some(menu) = self.context_menu.take() { - if let Some(idx) = item_idx { - return Some(MouseAction::ContextMenu { menu, idx }); - } else { - leave_modal(self); - } - } - return None; - } - - if self.on_sidebar_divider(mouse.column, mouse.row) { - self.drag = Some(DragState { - target: DragTarget::SidebarDivider, - }); - self.set_manual_sidebar_width(mouse.column); - return None; - } - - if self.on_sidebar_section_divider(mouse.column, mouse.row) { - self.drag = Some(DragState { - target: DragTarget::SidebarSectionDivider, - }); - self.set_sidebar_section_split(mouse.row); - return None; - } - - if !in_sidebar { - if let Some(border) = self.find_border_at(mouse.column, mouse.row) { - let grab_offset = match border.direction { - Direction::Horizontal => border.pos.saturating_sub(mouse.column), - Direction::Vertical => border.pos.saturating_sub(mouse.row), - }; - self.drag = Some(DragState { - target: DragTarget::PaneSplit { - path: border.path.clone(), - direction: border.direction, - area: border.area, - grab_offset, - }, - }); - return None; - } - - if let Some((pane_id, target)) = - self.scrollbar_target_at(terminal_runtimes, mouse.column, mouse.row) - { - self.focus_pane(pane_id); - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.drag = Some(DragState { - target: DragTarget::PaneScrollbar { - pane_id, - grab_row_offset, - }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_pane_scroll_offset( - terminal_runtimes, - pane_id, - offset_from_bottom, - ); - } - } - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } - return None; - } - } - - if self.mode_bar_covers_tab_row(mouse.column, mouse.row) { - return None; - } - - if self.on_tab_scroll_left_button(mouse.column, mouse.row) { - self.scroll_tabs_left(); - return None; - } - if self.on_tab_scroll_right_button(mouse.column, mouse.row) { - self.scroll_tabs_right(); - return None; - } - if let (Some(ws_idx), Some(tab_idx)) = - (self.active, self.tab_at(mouse.column, mouse.row)) - { - self.tab_presses.insert( - source_id, - TabPressState { - ws_idx, - tab_idx, - start_col: mouse.column, - start_row: mouse.row, - }, - ); - return None; - } - if self.on_new_tab_button(mouse.column, mouse.row) { - if self.prompt_new_tab_name { - open_new_tab_dialog(self); - } else { - self.request_new_tab = true; - self.mode = Mode::Terminal; - } - return None; - } - - if in_sidebar { - if self.on_sidebar_toggle(mouse.column, mouse.row) { - self.sidebar_collapsed = !self.sidebar_collapsed; - return None; - } - - if self.sidebar_collapsed { - if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusWorkspace { ws_idx: idx }); - } - - if let Some((ws_idx, _tab_idx, pane_id)) = - self.collapsed_agent_detail_target_at(mouse.row) - { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusPane { ws_idx, pane_id }); - } - return None; - } - - let new_button = self.sidebar_new_button_rect(); - let on_new_button = mouse.row >= new_button.y - && mouse.row < new_button.y + new_button.height - && mouse.column >= new_button.x - && mouse.column < new_button.x + new_button.width; - if on_new_button { - return Some(MouseAction::NewWorkspace); - } - - if let Some(target) = - self.workspace_list_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.drag = Some(DragState { - target: DragTarget::WorkspaceListScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_workspace_list_offset_from_bottom(offset_from_bottom); - } - } - return None; - } - - let cards = if self.view.workspace_card_areas.is_empty() { - crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) - } else { - self.view.workspace_card_areas.clone() - }; - if let Some(card) = cards.iter().find(|card| { - let chevron = crate::ui::workspace_group_chevron_rect(card); - mouse.row == chevron.y && mouse.column == chevron.x && chevron.width > 0 - }) { - if let Some((key, collapsed)) = - crate::ui::workspace_parent_group_state(self, card.ws_idx) - { - if collapsed { - self.collapsed_space_keys.remove(&key); - } else { - self.collapsed_space_keys.insert(key); - } - self.mark_session_dirty(); - return None; - } - } - - if let Some(idx) = self.workspace_at_row(mouse.row) { - self.workspace_presses.insert( - source_id, - WorkspacePressState { - ws_idx: idx, - start_col: mouse.column, - start_row: mouse.row, - }, - ); - return None; - } - - if self.on_agent_panel_sort_toggle(mouse.column, mouse.row) { - self.agent_panel_sort = match self.agent_panel_sort { - AgentPanelSort::Spaces => AgentPanelSort::Priority, - AgentPanelSort::Priority => AgentPanelSort::Spaces, - }; - self.agent_panel_scroll = 0; - self.mark_session_dirty(); - return None; - } - - if let Some(target) = - self.agent_panel_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.drag = Some(DragState { - target: DragTarget::AgentPanelScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_agent_panel_offset_from_bottom(offset_from_bottom); - } - } - return None; - } - - if let Some((ws_idx, _tab_idx, pane_id)) = - self.agent_detail_target_at(mouse.row) - { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusPane { ws_idx, pane_id }); - } - } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } - - if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { - self.selection = None; - self.selection_autoscroll = None; - return self.mouse_pane_focus_action(info.id); - } - - let (row, col) = ( - mouse.row - info.inner_rect.y, - mouse.column - info.inner_rect.x, - ); - self.selection = Some(Selection::anchor( - info.id, - row, - col, - self.pane_scroll_metrics(terminal_runtimes, info.id), - )); - return self.mouse_pane_focus_action(info.id); - } else if let Some(info) = self.view.pane_infos.iter().find(|p| { - mouse.column >= p.rect.x - && mouse.column < p.rect.x + p.rect.width - && mouse.row >= p.rect.y - && mouse.row < p.rect.y + p.rect.height - }) { - let id = info.id; - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } - return self.mouse_pane_focus_action(id); - } - } - - MouseEventKind::Drag(MouseButton::Left) => { - if self.selection.is_some() { - self.update_selection_drag(terminal_runtimes, mouse.column, mouse.row); - return None; - } - - if (self.drag.is_none() || self.chrome_drag_owned_by_other(source_id)) - && !self.chrome_press_pending(source_id) - { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { - self.selection = None; - self.selection_autoscroll = None; - return None; - } - } - } - - let workspace_drop_target = self.workspace_drop_target_at_row(mouse.row); - let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row); - if self.drag.is_none() { - if let Some(press) = self.workspace_presses.get(&source_id) { - let delta_col = mouse.column.abs_diff(press.start_col); - let delta_row = mouse.row.abs_diff(press.start_row); - let can_reorder = self.workspaces.get(press.ws_idx).is_some_and(|ws| { - ws.worktree_space() - .is_none_or(|space| !space.is_linked_worktree) - }); - if workspace_drop_target.is_some() - && can_reorder - && delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD - { - self.drag = Some(DragState { - target: DragTarget::WorkspaceReorder { - source_id, - source_ws_idx: press.ws_idx, - drop_target: workspace_drop_target, - }, - }); - } - } else if let Some(press) = self.tab_presses.get(&source_id) { - let delta_col = mouse.column.abs_diff(press.start_col); - let delta_row = mouse.row.abs_diff(press.start_row); - // Require a real drop target before opening a reorder, - // so a report from off the tab bar cannot start a drag - // that has nowhere to land. - if tab_drop_index.is_some() - && delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD - { - self.drag = Some(DragState { - target: DragTarget::TabReorder { - source_id, - ws_idx: press.ws_idx, - source_tab_idx: press.tab_idx, - insert_idx: tab_drop_index, - }, - }); - } - } - } - - if let Some(DragState { - target: - DragTarget::WorkspaceReorder { - source_id: drag_source_id, - drop_target, - .. - }, - }) = &mut self.drag - { - if *drag_source_id == source_id { - *drop_target = workspace_drop_target; - } - } else if let Some(DragState { - target: - DragTarget::TabReorder { - source_id: drag_source_id, - ws_idx, - insert_idx, - .. - }, - }) = &mut self.drag - { - if *drag_source_id == source_id && self.active == Some(*ws_idx) { - *insert_idx = tab_drop_index; - } - } else if let Some(drag) = &self.drag { - match &drag.target { - DragTarget::WorkspaceReorder { .. } | DragTarget::TabReorder { .. } => {} - DragTarget::WorkspaceListScrollbar { grab_row_offset } => { - if let Some(offset_from_bottom) = - self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.set_workspace_list_offset_from_bottom(offset_from_bottom); - } - } - DragTarget::AgentPanelScrollbar { grab_row_offset } => { - if let Some(offset_from_bottom) = - self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.set_agent_panel_offset_from_bottom(offset_from_bottom); - } - } - DragTarget::PaneSplit { - path, - direction, - area, - grab_offset, - } => { - let ratio = match direction { - Direction::Horizontal => { - (mouse - .column - .saturating_add(*grab_offset) - .saturating_sub(area.x)) - as f32 - / area.width.max(1) as f32 - } - Direction::Vertical => { - (mouse - .row - .saturating_add(*grab_offset) - .saturating_sub(area.y)) - as f32 - / area.height.max(1) as f32 - } - }; - let ratio = ratio.clamp(0.1, 0.9); - let path = path.clone(); - return Some(MouseAction::SetSplitRatio { path, ratio }); - } - DragTarget::PaneScrollbar { - pane_id, - grab_row_offset, - } => { - if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row( - terminal_runtimes, - *pane_id, - mouse.row, - *grab_row_offset, - ) { - self.set_pane_scroll_offset( - terminal_runtimes, - *pane_id, - offset_from_bottom, - ); - } - } - DragTarget::SidebarDivider => { - self.set_manual_sidebar_width(mouse.column); - } - DragTarget::SidebarSectionDivider => { - self.set_sidebar_section_split(mouse.row); - } - DragTarget::ReleaseNotesScrollbar { .. } - | DragTarget::ProductAnnouncementScrollbar { .. } - | DragTarget::KeybindHelpScrollbar { .. } => {} - } - } - } - - MouseEventKind::Up(MouseButton::Left) => { - // Mouse-up either finishes a drag selection or releases after a - // double-click word selection; the latter is already finalized. - if let Some(selection) = self.selection.as_ref() { - let was_click = selection.was_just_click(); - let was_finalized = selection.is_finalized(); - - self.clear_chrome_press(source_id); - self.drag = None; - self.selection_autoscroll = None; - if was_click { - self.selection = None; - } else if was_finalized { - // Double-click already finalized this word selection. - } else if self.copy_on_select { - self.copy_selection(terminal_runtimes); - } else if let Some(selection) = self.selection.as_mut() { - selection.finish(); - } - return None; - } - - let foreign_chrome_drag = self.chrome_drag_owned_by_other(source_id); - if (self.drag.is_none() || foreign_chrome_drag) - && !self.chrome_press_pending(source_id) - { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { - self.selection = None; - self.selection_autoscroll = None; - return None; - } - } - } - - let workspace_press = self.workspace_presses.remove(&source_id); - let tab_press = self.tab_presses.remove(&source_id); - if foreign_chrome_drag { - return self.chrome_press_action(workspace_press, tab_press); - } - - match self.drag.take() { - Some(DragState { - target: - DragTarget::WorkspaceReorder { - source_ws_idx, - drop_target: Some(drop_target), - .. - }, - }) => { - if let Some(params) = - self.workspace_move_block_params(source_ws_idx, drop_target) - { - if self - .workspaces - .get(source_ws_idx) - .is_some_and(|workspace| workspace.worktree_space().is_some()) - { - return Some(MouseAction::MoveWorkspaceBlock { params }); - } - let insert_idx = params - .before_workspace_id - .as_ref() - .and_then(|id| { - self.workspaces - .iter() - .position(|workspace| workspace.id == *id) - }) - .unwrap_or(self.workspaces.len()); - return Some(MouseAction::MoveWorkspace { - source_ws_idx, - insert_idx, - }); - } - } - Some(DragState { - target: - DragTarget::TabReorder { - ws_idx, - source_tab_idx, - insert_idx: Some(insert_idx), - .. - }, - }) => { - if self.active == Some(ws_idx) { - self.mode = Mode::Terminal; - return Some(MouseAction::MoveTab { - ws_idx, - source_tab_idx, - insert_idx, - }); - } - } - Some(_) => {} - None => return self.chrome_press_action(workspace_press, tab_press), - } - } - - MouseEventKind::Up(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Middle) - if !in_sidebar => - { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - let _ = self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); - } - } - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - if self.mode_bar_covers_tab_row(mouse.column, mouse.row) => {} - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - if self.on_tab_bar(mouse.column, mouse.row) => - { - match mouse.kind { - MouseEventKind::ScrollUp => { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if !ws.tabs.is_empty() { - let prev = if ws.active_tab == 0 { - ws.tabs.len() - 1 - } else { - ws.active_tab - 1 - }; - return Some(MouseAction::FocusTab { tab_idx: prev }); - } - } - } - MouseEventKind::ScrollDown => { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if !ws.tabs.is_empty() { - let next = (ws.active_tab + 1) % ws.tabs.len(); - return Some(MouseAction::FocusTab { tab_idx: next }); - } - } - } - _ => {} - } - } - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - if !in_sidebar && self.scroll_selection_with_wheel(terminal_runtimes, mouse) => {} - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { - self.selection = None; - self.selection_autoscroll = None; - self.handle_terminal_wheel(terminal_runtimes, mouse); - } - - MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight - if self.mode == Mode::Terminal && !in_sidebar => - { - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - self.forward_pane_reported_wheel(terminal_runtimes, &info, mouse); - } - } - - MouseEventKind::ScrollUp if in_sidebar => { - let agent_area = self.agent_panel_rect(); - let over_agent_panel = agent_area != Rect::default() - && mouse.row >= agent_area.y - && mouse.row < agent_area.y + agent_area.height; - if over_agent_panel { - if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( - self, agent_area, - )) { - self.scroll_agent_panel(-1); - } - } else if crate::ui::should_show_scrollbar( - crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), - ) { - self.scroll_workspace_list(-1); - } else { - self.move_selected_workspace_by_visible_delta(-1); - } - } - MouseEventKind::ScrollDown if in_sidebar => { - let agent_area = self.agent_panel_rect(); - let over_agent_panel = agent_area != Rect::default() - && mouse.row >= agent_area.y - && mouse.row < agent_area.y + agent_area.height; - if over_agent_panel { - if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( - self, agent_area, - )) { - self.scroll_agent_panel(1); - } - } else if crate::ui::should_show_scrollbar( - crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), - ) { - self.scroll_workspace_list(1); - } else { - self.move_selected_workspace_by_visible_delta(1); - } - } - - MouseEventKind::Moved if self.mode == Mode::ContextMenu => { - let hovered = self.context_menu_item_at(mouse.column, mouse.row); - if let Some(menu) = &mut self.context_menu { - menu.list.hover(hovered); - } - } - - MouseEventKind::Moved if self.mode == Mode::Terminal && !in_sidebar => { - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - let _ = self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse); - } - } - - MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => { - self.clear_chrome_press(source_id); - if self - .workspace_list_scrollbar_target_at(mouse.column, mouse.row) - .is_some() - { - return None; - } - if let Some(idx) = self.workspace_at_row(mouse.row) { - self.selected = idx; - let kind = self - .workspaces - .get(idx) - .and_then(|ws| { - let group_state = crate::ui::workspace_parent_group_state(self, idx); - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from(&self.terminals, terminal_runtimes) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - let is_linked_worktree = ws.worktree_space().map_or_else( - || { - git_space - .as_ref() - .is_some_and(|space| space.is_linked_worktree) - }, - |space| space.is_linked_worktree, - ); - let show_git_menu = ws.worktree_space().is_some() - || git_space - .as_ref() - .is_some_and(|space| !space.is_linked_worktree); - show_git_menu.then_some(ContextMenuKind::GitWorkspace { - ws_idx: idx, - is_linked_worktree, - has_worktree_children: group_state.is_some(), - collapsed: group_state - .as_ref() - .is_some_and(|(_, collapsed)| *collapsed), - }) - }) - .unwrap_or(ContextMenuKind::Workspace { ws_idx: idx }); - self.context_menu = Some(ContextMenuState { - kind, - x: mouse.column, - y: mouse.row, - list: MenuListState::new(0), - }); - self.mode = Mode::ContextMenu; - } - } - - MouseEventKind::Down(MouseButton::Right) - if !self.mode_bar_covers_tab_row(mouse.column, mouse.row) - && self.tab_at(mouse.column, mouse.row).is_some() => - { - if let (Some(ws_idx), Some(tab_idx)) = - (self.active, self.tab_at(mouse.column, mouse.row)) - { - self.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Tab { ws_idx, tab_idx }, - x: mouse.column, - y: mouse.row, - list: MenuListState::new(0), - }); - self.mode = Mode::ContextMenu; - } - } - - MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - let ws_idx = self.active?; - let tab_idx = self - .workspaces - .get(ws_idx) - .map(|ws| ws.active_tab_index())?; - let previous_focused_pane_id = self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.focused_pane_id()); - let source_pane_id = - previous_focused_pane_id.filter(|pane_id| *pane_id != info.id); - let pane_state = self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.pane_state(info.id)); - let has_manual_label = pane_state - .and_then(|pane| self.terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| terminal.manual_label.as_ref()) - .is_some(); - let right_click_passthrough = - pane_state.is_some_and(|pane| pane.right_click_passthrough); - self.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id: info.id, - source_pane_id, - has_manual_label, - right_click_passthrough, - }, - x: mouse.column, - y: mouse.row, - list: MenuListState::new(0), - }); - self.mode = Mode::ContextMenu; - } - } - - _ => {} - } - - None - } - - fn handle_mobile_mouse(&mut self, mouse: MouseEvent) -> MobileMouseResult { - if self.mode == Mode::Navigate { - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll_mobile_switcher_at(mouse.column, mouse.row, -1); - return MobileMouseResult::Consumed; - } - MouseEventKind::ScrollDown => { - self.scroll_mobile_switcher_at(mouse.column, mouse.row, 1); - return MobileMouseResult::Consumed; - } - MouseEventKind::Down(MouseButton::Left) => {} - _ => return MobileMouseResult::Consumed, - } - } else if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - return MobileMouseResult::Ignored; - } - - if self.mode != Mode::Navigate { - if !matches!(self.mode, Mode::Terminal | Mode::Resize) { - return MobileMouseResult::Ignored; - } - if rect_contains(self.view.mobile_menu_hit_area, mouse.column, mouse.row) { - self.mobile_switcher_scroll = 0; - self.mode = Mode::Navigate; - return MobileMouseResult::Consumed; - } - return MobileMouseResult::Ignored; - } - - let areas = crate::ui::mobile_switcher_areas(self); - if rect_contains(areas.close, mouse.column, mouse.row) { - self.mode = Mode::Terminal; - return MobileMouseResult::Consumed; - } - - match crate::ui::mobile_switcher_target_at(self, mouse.column, mouse.row) { - Some(crate::ui::MobileSwitcherTarget::NewWorkspace) => { - return MobileMouseResult::Action(MouseAction::NewWorkspace); - } - Some(crate::ui::MobileSwitcherTarget::Workspace(ws_idx)) => { - self.mode = Mode::Terminal; - return MobileMouseResult::Action(MouseAction::FocusWorkspace { ws_idx }); - } - Some(crate::ui::MobileSwitcherTarget::NewTab) => { - if self.prompt_new_tab_name { - open_new_tab_dialog(self); - } else { - self.request_new_tab = true; - self.mode = Mode::Terminal; - } - } - Some(crate::ui::MobileSwitcherTarget::Tab(tab_idx)) => { - self.mode = Mode::Terminal; - return MobileMouseResult::Action(MouseAction::FocusTab { tab_idx }); - } - Some(crate::ui::MobileSwitcherTarget::Agent { - ws_idx, - tab_idx: _, - pane_id, - }) => { - self.mode = Mode::Terminal; - return MobileMouseResult::Action(MouseAction::FocusPane { ws_idx, pane_id }); - } - Some(crate::ui::MobileSwitcherTarget::Menu(action_idx)) => { - let actions = global_menu_actions(self); - if let Some(action) = actions.get(action_idx).copied() { - apply_global_menu_action(self, action); - } - } - None => {} - } - - MobileMouseResult::Consumed - } - - fn scroll_mobile_switcher_at(&mut self, _col: u16, _row: u16, delta: i16) { - let max_scroll = crate::ui::mobile_switcher_max_scroll(self); - apply_scroll( - &mut self.mobile_switcher_scroll, - delta.saturating_mul(2), - max_scroll, - ); - } - - pub(super) fn screen_rect(&self) -> Rect { - let sidebar = self.view.sidebar_rect; - let terminal = self.view.terminal_area; - let x = sidebar.x.min(terminal.x); - let y = sidebar.y.min(terminal.y); - let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width); - let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height); - Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) - } - - pub(crate) fn context_menu_rect(&self) -> Option { - let menu = self.context_menu.as_ref()?; - let screen = self.screen_rect(); - let max_item_w = menu - .items() - .iter() - .map(|item| item.len() as u16) - .max() - .unwrap_or(0); - let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1)); - let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1)); - let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w)); - let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h)); - Some(Rect::new(x, y, menu_w, menu_h)) - } - - pub(crate) fn confirm_close_rect(&self) -> Rect { - crate::ui::confirm_close_popup_rect(self.view.terminal_area).unwrap_or_default() - } - - fn context_menu_item_at(&self, col: u16, row: u16) -> Option { - let menu_rect = self.context_menu_rect()?; - let inner_x = menu_rect.x + 1; - let inner_y = menu_rect.y + 1; - let inner_w = menu_rect.width.saturating_sub(2); - let inner_h = menu_rect.height.saturating_sub(2); - let item_count = self - .context_menu - .as_ref() - .map(|menu| menu.items().len() as u16) - .unwrap_or(0); - if col >= inner_x - && col < inner_x + inner_w - && row >= inner_y - && row < inner_y + inner_h.min(item_count) - { - Some((row - inner_y) as usize) - } else { - None - } - } - - pub(super) fn tab_at(&self, col: u16, row: u16) -> Option { - self.view - .tab_hit_areas - .iter() - .enumerate() - .find_map(|(idx, area)| { - (area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width) - .then_some(idx) - }) - } - - fn mode_bar_covers_tab_row(&self, col: u16, row: u16) -> bool { - self.tab_bar_position == crate::config::TabBarPositionConfig::Bottom - && matches!( - self.mode, - Mode::Navigate | Mode::Prefix | Mode::Copy | Mode::Resize - ) - && self.on_tab_bar(col, row) - } - - pub(super) fn on_tab_bar(&self, col: u16, row: u16) -> bool { - let area = self.view.tab_bar_rect; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - pub(super) fn on_tab_scroll_left_button(&self, col: u16, row: u16) -> bool { - let area = self.view.tab_scroll_left_hit_area; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - pub(super) fn on_tab_scroll_right_button(&self, col: u16, row: u16) -> bool { - let area = self.view.tab_scroll_right_hit_area; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - pub(super) fn tab_drop_index_at(&self, col: u16, row: u16) -> Option { - if !self.on_tab_bar(col, row) { - return None; - } - - let visible_tabs: Vec<_> = self - .view - .tab_hit_areas - .iter() - .enumerate() - .filter(|(_, rect)| rect.width > 0) - .collect(); - let (first_idx, first_rect) = *visible_tabs.first()?; - let (last_idx, last_rect) = *visible_tabs.last()?; - - if self.on_tab_scroll_left_button(col, row) { - return Some(0); - } - if self.on_tab_scroll_right_button(col, row) { - return self - .active - .and_then(|idx| self.workspaces.get(idx)) - .map(|ws| ws.tabs.len()); - } - - let left_edge = if first_idx == 0 { - first_rect.x - } else { - self.view.tab_scroll_left_hit_area.x + self.view.tab_scroll_left_hit_area.width - }; - let right_edge = if self - .active - .and_then(|idx| self.workspaces.get(idx)) - .is_some_and(|ws| last_idx + 1 >= ws.tabs.len()) - { - last_rect.x + last_rect.width - } else { - self.view.tab_scroll_right_hit_area.x.saturating_sub(1) - }; - - if col <= left_edge { - return Some(first_idx); - } - if col >= right_edge { - return Some(last_idx + 1); - } - - for (idx, rect) in visible_tabs { - let midpoint = rect.x + rect.width / 2; - if col < midpoint { - return Some(idx); - } - if col < rect.x + rect.width { - return Some(idx + 1); - } - } - - Some(last_idx + 1) - } - - pub(super) fn on_new_tab_button(&self, col: u16, row: u16) -> bool { - let area = self.view.new_tab_hit_area; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - pub(super) fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> { - self.view.split_borders.iter().find(|b| match b.direction { - Direction::Horizontal if self.pane_borders && !self.pane_gaps => { - col == b.pos && row >= b.area.y && row < b.area.y + b.area.height - } - Direction::Horizontal if self.pane_borders && self.pane_gaps => { - row >= b.area.y - && row < b.area.y + b.area.height - && col >= b.pos.saturating_sub(1) - && col <= b.pos - } - Direction::Horizontal if !self.pane_borders && self.pane_gaps => { - row >= b.area.y - && row < b.area.y + b.area.height - && b.pos.checked_sub(1).is_some_and(|gap_col| { - col == gap_col && self.pane_frame_at(col, row).is_none() - }) - } - Direction::Vertical if self.pane_borders && !self.pane_gaps => { - row == b.pos && col >= b.area.x && col < b.area.x + b.area.width - } - Direction::Vertical if self.pane_borders && self.pane_gaps => { - col >= b.area.x - && col < b.area.x + b.area.width - && row >= b.pos.saturating_sub(1) - && row <= b.pos - } - Direction::Vertical if !self.pane_borders && self.pane_gaps => { - col >= b.area.x - && col < b.area.x + b.area.width - && b.pos.checked_sub(1).is_some_and(|gap_row| { - row == gap_row && self.pane_frame_at(col, row).is_none() - }) - } - _ => false, - }) - } - - pub(super) fn pane_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { - self.view.pane_infos.iter().find(|p| { - col >= p.inner_rect.x - && col < p.inner_rect.x + p.inner_rect.width - && row >= p.inner_rect.y - && row < p.inner_rect.y + p.inner_rect.height - }) - } - - pub(super) fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> { - self.pane_at(col, row) - .or_else(|| self.pane_frame_at(col, row)) - } - - fn chrome_press_pending(&self, source_id: crate::app::InputSourceId) -> bool { - self.tab_presses.contains_key(&source_id) || self.workspace_presses.contains_key(&source_id) - } - - fn chrome_drag_owned_by_other(&self, source_id: crate::app::InputSourceId) -> bool { - self.drag.as_ref().is_some_and(|drag| { - matches!( - drag.target, - DragTarget::WorkspaceReorder { - source_id: drag_source_id, - .. - } | DragTarget::TabReorder { - source_id: drag_source_id, - .. - } if drag_source_id != source_id - ) - }) - } - - fn chrome_press_action( - &mut self, - workspace_press: Option, - tab_press: Option, - ) -> Option { - if let Some(press) = workspace_press { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusWorkspace { - ws_idx: press.ws_idx, - }); - } - if let Some(press) = tab_press { - if self.active == Some(press.ws_idx) { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusTab { - tab_idx: press.tab_idx, - }); - } - } - None - } - - pub(crate) fn clear_chrome_gesture(&mut self, source_id: crate::app::InputSourceId) { - if self.drag.as_ref().is_some_and(|drag| { - matches!( - drag.target, - DragTarget::WorkspaceReorder { - source_id: drag_source_id, - .. - } | DragTarget::TabReorder { - source_id: drag_source_id, - .. - } if drag_source_id == source_id - ) - }) { - self.drag = None; - } - self.clear_chrome_press(source_id); - } - - fn clear_chrome_press(&mut self, source_id: crate::app::InputSourceId) { - self.tab_presses.remove(&source_id); - self.workspace_presses.remove(&source_id); - } - - fn mouse_pane_focus_action(&self, pane_id: crate::layout::PaneId) -> Option { - let ws_idx = self.active?; - (self - .workspaces - .get(ws_idx) - .and_then(|workspace| workspace.focused_pane_id()) - != Some(pane_id)) - .then_some(MouseAction::FocusPane { ws_idx, pane_id }) - } - - pub(crate) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> { - self.view.pane_infos.iter().find(|info| info.id == pane_id) - } - - pub(super) fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { - self.view.pane_infos.iter().find(|p| { - col >= p.rect.x - && col < p.rect.x + p.rect.width - && row >= p.rect.y - && row < p.rect.y + p.rect.height - }) - } - - pub(super) fn focus_pane(&mut self, pane_id: crate::layout::PaneId) { - let _ = pane_id; - } - - fn clickable_toast_at(&self, col: u16, row: u16) -> bool { - self.toast - .as_ref() - .is_some_and(|toast| toast.target.is_some()) - && rect_contains(self.view.toast_hit_area, col, row) - } - - #[cfg(test)] - pub(crate) fn focus_toast_target(&mut self) { - let Some(target) = self.toast.as_ref().and_then(|toast| toast.target.clone()) else { - return; - }; - let Some(ws_idx) = self - .workspaces - .iter() - .position(|workspace| workspace.id == target.workspace_id) - else { - return; - }; - let Some(_tab_idx) = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id) else { - return; - }; - - self.focus_pane_in_workspace(ws_idx, target.pane_id); - self.toast = None; - self.settle_terminal_mode_after_focus(); - } - - pub(crate) fn scroll_pane_up( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - lines: usize, - ) { - if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - { - rt.scroll_up(lines); - } - } - } - - pub(crate) fn scroll_pane_down( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - lines: usize, - ) { - if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - { - rt.scroll_down(lines); - } - } - } - - pub(crate) fn pane_scroll_metrics( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - ) -> Option { - self.active - .and_then(|i| self.runtime_for_pane_in_workspace(terminal_runtimes, i, pane_id)) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - } - - fn handle_right_click_passthrough( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - source_id: crate::app::InputSourceId, - mouse: MouseEvent, - in_sidebar: bool, - ) -> bool { - if let Some(gesture) = self.right_click_passthrough.clone() { - match mouse.kind { - MouseEventKind::Drag(MouseButton::Right) - | MouseEventKind::Up(MouseButton::Right) => { - let forwarded_mouse = - self.strip_right_click_passthrough_modifiers(mouse, gesture.modifiers); - let _ = self.forward_pane_mouse_button( - terminal_runtimes, - &gesture.pane_info, - forwarded_mouse, - ); - if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Right)) { - self.right_click_passthrough = None; - } - return true; - } - _ => { - self.right_click_passthrough = None; - } - } - } - - if self.mode != Mode::Terminal - || in_sidebar - || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) - { - return false; - } - - let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { - return false; - }; - let configured_modifiers = self - .right_click_passthrough_modifiers - .filter(|modifiers| mouse.modifiers == *modifiers); - let pane_passthrough = mouse.modifiers.is_empty() - && self.active.is_some_and(|ws_idx| { - self.workspaces - .get(ws_idx) - .and_then(|workspace| workspace.pane_state(info.id)) - .is_some_and(|pane| pane.right_click_passthrough) - }); - let Some(modifiers) = configured_modifiers - .or_else(|| pane_passthrough.then(crossterm::event::KeyModifiers::empty)) - else { - return false; - }; - - self.focus_pane(info.id); - let forwarded_mouse = self.strip_right_click_passthrough_modifiers(mouse, modifiers); - if !self.forward_pane_mouse_button(terminal_runtimes, &info, forwarded_mouse) { - return false; - } - - self.selection = None; - self.selection_autoscroll = None; - self.clear_chrome_press(source_id); - self.drag = None; - self.context_menu = None; - self.right_click_passthrough = Some(RightClickPassthroughGesture { - pane_info: info, - modifiers, - }); - true - } - - fn strip_right_click_passthrough_modifiers( - &self, - mouse: MouseEvent, - modifiers: crossterm::event::KeyModifiers, - ) -> MouseEvent { - MouseEvent { - modifiers: mouse.modifiers.difference(modifiers), - ..mouse - } - } - - pub(super) fn handle_terminal_wheel( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - mouse: MouseEvent, - ) { - let lines_per_notch = self.mouse_scroll_lines; - - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - if self.forward_pane_wheel(terminal_runtimes, &info, mouse) { - return; - } - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll_pane_up(terminal_runtimes, info.id, lines_per_notch) - } - MouseEventKind::ScrollDown => { - self.scroll_pane_down(terminal_runtimes, info.id, lines_per_notch) - } - _ => {} - } - return; - } - - if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll_pane_up(terminal_runtimes, info.id, lines_per_notch) - } - MouseEventKind::ScrollDown => { - self.scroll_pane_down(terminal_runtimes, info.id, lines_per_notch) - } - _ => {} - } - return; - } - - if let Some(ws_idx) = self.active { - if let Some(rt) = self.focused_runtime_in_workspace(terminal_runtimes, ws_idx) { - match mouse.kind { - MouseEventKind::ScrollUp => rt.scroll_up(lines_per_notch), - MouseEventKind::ScrollDown => rt.scroll_down(lines_per_notch), - _ => {} - } - } - } - } - - fn pane_mouse_position( - &self, - runtime: &crate::terminal::TerminalRuntime, - inner: Rect, - mouse: MouseEvent, - ) -> Option { - let column = mouse.column.saturating_sub(inner.x); - let row = mouse.row.saturating_sub(inner.y); - let cell = crate::input::mouse::Position::Cell { column, row }; - let Some(host) = self.host_mouse_pixels else { - return Some(cell); - }; - let wants_pixels = runtime.sgr_pixel_mouse_enabled(); - if !wants_pixels { - return Some(cell); - } - let Some((width_px, height_px)) = runtime.pixel_size() else { - return Some(cell); - }; - Some( - host.pane_position(inner, width_px, height_px) - .unwrap_or(cell), - ) - } - - pub(super) fn forward_pane_mouse_button( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - info: &PaneInfo, - mouse: MouseEvent, - ) -> bool { - let Some(ws_idx) = self.active else { - return false; - }; - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - else { - return false; - }; - let Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else { - return false; - }; - let Some(bytes) = rt.encode_mouse_button(mouse.kind, position, mouse.modifiers) else { - return false; - }; - rt.scroll_reset(); - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse button event"); - } - true - } - - pub(super) fn forward_pane_mouse_motion( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - info: &PaneInfo, - mouse: MouseEvent, - ) -> bool { - let Some(ws_idx) = self.active else { - return false; - }; - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - else { - return false; - }; - let Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else { - return false; - }; - let Some(bytes) = rt.encode_mouse_motion(mouse.kind, position, mouse.modifiers) else { - return false; - }; - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse motion event"); - } - true - } - - fn forward_pane_reported_wheel( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - info: &PaneInfo, - mouse: MouseEvent, - ) -> bool { - let Some(ws_idx) = self.active else { - return false; - }; - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - else { - return false; - }; - if rt.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) { - return false; - } - rt.scroll_reset(); - let Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else { - return false; - }; - let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, position, mouse.modifiers) else { - warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event"); - return true; - }; - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event"); - } - true - } - - pub(super) fn forward_pane_wheel( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - info: &PaneInfo, - mouse: MouseEvent, - ) -> bool { - let Some(ws_idx) = self.active else { - return false; - }; - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - else { - return false; - }; - match rt.wheel_routing() { - Some(crate::pane::WheelRouting::HostScroll) | None => false, - Some(crate::pane::WheelRouting::MouseReport) => { - self.forward_pane_reported_wheel(terminal_runtimes, info, mouse) - } - Some(crate::pane::WheelRouting::AlternateScroll) => { - rt.scroll_reset(); - let Some(bytes) = rt.encode_alternate_scroll(mouse.kind) else { - return true; - }; - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key"); - } - true - } - } - } - - pub(super) fn set_pane_scroll_offset( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - offset_from_bottom: usize, - ) { - for ws_idx in 0..self.workspaces.len() { - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - else { - continue; - }; - rt.set_scroll_offset_from_bottom(offset_from_bottom); - return; - } - } - - pub(super) fn scrollbar_target_at( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - col: u16, - row: u16, - ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { - let ws_idx = self.active?; - let info = self.view.pane_infos.iter().find(|info| { - crate::ui::pane_scrollbar_rect(info).is_some_and(|track| { - col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height - }) - })?; - let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?; - let metrics = rt.scroll_metrics()?; - if metrics.max_offset_from_bottom == 0 { - return None; - } - let track = crate::ui::pane_scrollbar_rect(info)?; - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset })) - } else { - Some(( - info.id, - ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }, - )) - } - } - - pub(super) fn scrollbar_offset_for_pane_row( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - row: u16, - grab_row_offset: u16, - ) -> Option { - let ws_idx = self.active?; - let info = self - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id)?; - let track = crate::ui::pane_scrollbar_rect(info)?; - let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; - let metrics = rt.scroll_metrics()?; - if metrics.max_offset_from_bottom == 0 { - return None; - } - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } -} - -#[cfg(test)] -pub(super) fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting { - if input_state.mouse_protocol_mode.reporting_enabled() { - WheelRouting::MouseReport - } else if input_state.alternate_screen && input_state.mouse_alternate_scroll { - WheelRouting::AlternateScroll - } else { - WheelRouting::HostScroll - } -} - -fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { - rect.width > 0 - && rect.height > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height -} - -fn apply_scroll(scroll: &mut usize, delta: i16, max_scroll: usize) { - if delta.is_negative() { - *scroll = scroll.saturating_sub(delta.unsigned_abs() as usize); - } else { - *scroll = scroll.saturating_add(delta as usize).min(max_scroll); - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; - use ratatui::layout::{Direction, Rect}; - - use super::super::{ - app_for_mouse_test, capture_snapshot, mouse, numbered_lines_bytes, root_layout_ratio, - }; - use super::*; - use crate::app::input::modal::handle_context_menu_key; - use crate::{ - app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode, ViewLayout}, - detect::{Agent, AgentState}, - workspace::Workspace, - }; - - #[test] - fn tab_click_survives_stray_drag_report_off_the_tab_bar() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.active_tab = 1; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let first_tab = app.state.view.tab_hit_areas[0]; - let press_col = first_tab.x + 1; - let stray_row = area.height - 1; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - press_col, - first_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - press_col, - stray_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - press_col, - stray_row, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - #[test] - fn workspace_click_survives_stray_drag_report_off_the_workspace_list() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("first"), Workspace::test_new("second")]; - app.state.active = Some(1); - app.state.selected = 1; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let first_workspace = app.state.view.workspace_card_areas[0]; - let press_col = first_workspace.rect.x + 1; - let press_row = first_workspace.rect.y; - let stray_row = area.height - 1; - assert!(app.state.workspace_drop_target_at_row(stray_row).is_none()); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - press_col, - press_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - press_col, - stray_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - press_col, - stray_row, - )); - - assert_eq!(app.state.active, Some(0)); - } - - #[test] - fn concurrent_input_sources_keep_their_tab_clicks() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.test_add_tab(None); - ws.active_tab = 2; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first_tab = app.state.view.tab_hit_areas[0]; - let second_tab = app.state.view.tab_hit_areas[1]; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Up(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - assert_eq!(app.state.workspaces[0].active_tab, 0); - - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - assert_eq!(app.state.workspaces[0].active_tab, 1); - } - - #[test] - fn concurrent_input_sources_keep_their_workspace_clicks() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("first"), - Workspace::test_new("second"), - Workspace::test_new("third"), - ]; - app.state.active = Some(2); - app.state.selected = 2; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first = app.state.view.workspace_card_areas[0].rect; - let second = app.state.view.workspace_card_areas[1].rect; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first.x + 1, - first.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second.x + 1, - second.y, - ), - ); - - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Up(MouseButton::Left), first.x + 1, first.y), - ); - assert_eq!(app.state.active, Some(0)); - - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Up(MouseButton::Left), - second.x + 1, - second.y, - ), - ); - assert_eq!(app.state.active, Some(1)); - } - - #[test] - fn tab_click_completes_while_other_source_reorders() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.test_add_tab(None); - ws.active_tab = 2; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first_tab = app.state.view.tab_hit_areas[0]; - let second_tab = app.state.view.tab_hit_areas[1]; - let last_tab = app.state.view.tab_hit_areas[2]; - let drop_col = last_tab.x + last_tab.width; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Drag(MouseButton::Left), - drop_col, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - assert_eq!(app.state.workspaces[0].active_tab, 1); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::TabReorder { source_id: 41, .. }) - )); - - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Up(MouseButton::Left), drop_col, first_tab.y), - ); - assert!(app.state.drag.is_none()); - } - - #[test] - fn releasing_input_source_clears_only_its_pending_tab_click() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first_tab = app.state.view.tab_hit_areas[0]; - let second_tab = app.state.view.tab_hit_areas[1]; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - - app.clear_input_source(41); - - assert!(!app.state.tab_presses.contains_key(&41)); - assert!(app.state.tab_presses.contains_key(&42)); - } - - #[tokio::test] - async fn other_input_source_pane_gesture_is_not_swallowed_by_chrome_press() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let info = app.state.view.pane_infos[0].clone(); - let pane_id = info.id; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1000h\x1b[?1006h", - 4, - ); - app.state.insert_test_runtime(pane_id, runtime); - crate::ui::compute_view(&mut app.state, area); - - let first_tab = app.state.view.tab_hit_areas[0]; - let pane_col = info.inner_rect.x; - let pane_row = info.inner_rect.y; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse(MouseEventKind::Down(MouseButton::Left), pane_col, pane_row), - ); - app.handle_mouse_from_input_source( - 42, - mouse(MouseEventKind::Up(MouseButton::Left), pane_col, pane_row), - ); - - assert_eq!( - input_rx.try_recv().expect("other source mouse down"), - Bytes::from_static(b"\x1b[<0;1;1M") - ); - assert_eq!( - input_rx.try_recv().expect("other source mouse up"), - Bytes::from_static(b"\x1b[<0;1;1m") - ); - } - - #[test] - fn other_input_source_cannot_release_tab_reorder() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("second")); - ws.test_add_tab(Some("third")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let source = app.state.view.tab_hit_areas[0]; - let target = app.state.view.tab_hit_areas[2]; - let drop_col = target.x + target.width; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - source.x + 1, - source.y, - ), - ); - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Drag(MouseButton::Left), drop_col, source.y), - ); - app.handle_mouse_from_input_source( - 42, - mouse(MouseEventKind::Up(MouseButton::Left), drop_col, source.y), - ); - - assert!(app.state.drag.is_some()); - assert_eq!(app.state.workspaces[0].tabs[0].custom_name, None); - - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Up(MouseButton::Left), drop_col, source.y), - ); - - assert!(app.state.drag.is_none()); - assert_eq!(app.state.workspaces[0].tabs[2].custom_name.as_deref(), None); - } - - #[tokio::test] - async fn tab_click_survives_stray_drag_report_into_a_mouse_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.active_tab = 1; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let info = app - .state - .view - .pane_infos - .first() - .cloned() - .expect("visible pane"); - app.state.insert_test_runtime( - info.id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - info.inner_rect.width.max(1), - info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - crate::ui::compute_view(&mut app.state, area); - - let first_tab = app.state.view.tab_hit_areas[0]; - let press_col = first_tab.x + 1; - let stray_row = info.inner_rect.bottom().saturating_sub(1); - assert!( - app.state.pane_mouse_target(press_col, stray_row).is_some(), - "stray coordinates must land on the pane for this to test anything" - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - press_col, - first_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - press_col, - stray_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - press_col, - stray_row, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - fn mark_worktree_space_member(workspace: &mut Workspace, ws_idx: usize, key: &str) { - workspace.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/worktree-{ws_idx}").into(), - is_linked_worktree: ws_idx != 0, - }); - } - - #[tokio::test] - async fn terminal_wheel_uses_configured_mouse_scroll_lines() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - app.state.mouse_scroll_lines = 7; - - app.handle_mouse(mouse( - MouseEventKind::ScrollUp, - info.inner_rect.x + 1, - info.inner_rect.y + 1, - )); - - let metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after wheel"); - assert_eq!(metrics.offset_from_bottom, 7); - } - - #[tokio::test] - async fn mouse_dispatcher_forwards_horizontal_wheel_to_mouse_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1000h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - assert!( - app.state.mouse_capture, - "reproduction must use the default Herdr mouse dispatcher" - ); - - let outer_column = info.inner_rect.x + 2; - let outer_row = info.inner_rect.y + 3; - for (button, expected_kind) in [ - (66, MouseEventKind::ScrollLeft), - (67, MouseEventKind::ScrollRight), - ] { - let input = format!("\x1b[<{button};{};{}M", outer_column + 1, outer_row + 1); - let mut events = crate::raw_input::parse_raw_input_bytes_sync(input.as_bytes()); - let event = events - .pop() - .expect("horizontal SGR wheel input should parse"); - let crate::raw_input::RawInputEvent::Mouse(mouse) = &event else { - panic!("expected parsed mouse event"); - }; - assert!(events.is_empty(), "expected one parsed mouse event"); - assert_eq!(mouse.kind, expected_kind); - - app.route_client_events(vec![event], false); - - assert_eq!( - input_rx - .try_recv() - .expect("horizontal wheel should reach pane"), - Bytes::from(format!("\x1b[<{button};3;4M")) - ); - } - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn horizontal_wheel_stays_inert_for_non_mouse_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"", - 1, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let input = format!( - "\x1b[<66;{};{}M", - info.inner_rect.x + 3, - info.inner_rect.y + 4 - ); - let event = crate::raw_input::parse_raw_input_bytes_sync(input.as_bytes()) - .pop() - .expect("horizontal SGR wheel input should parse"); - - app.route_client_events(vec![event], false); - - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn pane_right_click_passthrough_is_isolated() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let passthrough_pane = ws.tabs[0].root_pane; - let default_pane = ws.test_split(Direction::Horizontal); - ws.pane_state_mut(passthrough_pane) - .unwrap() - .right_click_passthrough = true; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let passthrough_info = app.state.pane_info_by_id(passthrough_pane).unwrap().clone(); - let default_info = app.state.pane_info_by_id(default_pane).unwrap().clone(); - let (passthrough_runtime, mut passthrough_input) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - passthrough_info.inner_rect.width, - passthrough_info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - let (default_runtime, mut default_input) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - default_info.inner_rect.width, - default_info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - app.state - .insert_test_runtime(passthrough_pane, passthrough_runtime); - app.state.insert_test_runtime(default_pane, default_runtime); - - let col = passthrough_info.inner_rect.x + 2; - let row = passthrough_info.inner_rect.y + 3; - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Right), col, row)); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.context_menu.is_none()); - assert_eq!( - passthrough_input.try_recv().unwrap(), - Bytes::from_static(b"\x1b[<2;3;4M") - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - default_info.inner_rect.x + 2, - default_info.inner_rect.y + 3, - )); - - assert!(default_input.try_recv().is_err()); - assert!(matches!( - app.state.context_menu.as_ref().map(|menu| &menu.kind), - Some(ContextMenuKind::Pane { pane_id, .. }) if *pane_id == default_pane - )); - } - - #[tokio::test] - async fn pane_right_click_passthrough_falls_back_when_mouse_reporting_is_off() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.pane_state_mut(pane_id).unwrap().right_click_passthrough = true; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.pane_info_by_id(pane_id).unwrap().clone(); - app.state.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - info.inner_rect.width, - info.inner_rect.height, - b"", - ), - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - info.inner_rect.x + 2, - info.inner_rect.y + 3, - )); - - assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); - } - - #[tokio::test] - async fn configured_right_click_passthrough_forwards_gesture_outside_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); - - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y + 3; - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse(MouseEventKind::Down(MouseButton::Right), col, row) - }); - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse(MouseEventKind::Drag(MouseButton::Right), 0, 0) - }); - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse(MouseEventKind::Up(MouseButton::Right), 0, 0) - }); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.context_menu.is_none()); - assert!(app.state.right_click_passthrough.is_none()); - assert_eq!( - input_rx.try_recv().expect("forwarded right mouse down"), - Bytes::from_static(b"\x1b[<2;3;4M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded right mouse drag"), - Bytes::from_static(b"\x1b[<34;1;1M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded right mouse up"), - Bytes::from_static(b"\x1b[<2;1;1m") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn captured_left_press_focuses_target_before_forwarding() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let source = ws.tabs[0].root_pane; - let target = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(source); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app - .state - .pane_info_by_id(target) - .expect("target pane info") - .clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - app.state.insert_test_runtime(target, runtime); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - info.inner_rect.x + 1, - info.inner_rect.y + 1, - )); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(target)); - assert_eq!( - input_rx.try_recv().expect("forwarded captured left press"), - Bytes::from_static(b"\x1b[<0;2;2M") - ); - } - - #[tokio::test] - async fn pane_mouse_only_forwards_moved_events_for_any_motion_apps() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1003h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - app.state.handle_pane_mouse_only( - &app.terminal_runtimes, - mouse( - MouseEventKind::Moved, - info.inner_rect.x + 2, - info.inner_rect.y + 3, - ), - ); - - assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), - Bytes::from_static(b"\x1b[<35;3;4M") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn pane_mouse_motion_uses_computed_inner_rect_offsets() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 18, - 0, - b"\x1b[?1003h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.view.pane_infos[0].clone(); - assert!(info.inner_rect.x > 0, "sidebar offset should be present"); - assert!(info.inner_rect.y > 0, "tab bar offset should be present"); - - app.state.handle_pane_mouse_only( - &app.terminal_runtimes, - mouse( - MouseEventKind::Moved, - info.inner_rect.x + 2, - info.inner_rect.y + 3, - ), - ); - - assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), - Bytes::from_static(b"\x1b[<35;3;4M") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn ordinary_cell_mouse_downgrades_pixel_mode_to_cell_coordinates() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 18, - 0, - b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.host_cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.view.pane_infos[0].clone(); - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .unwrap() - .resize(info.inner_rect.height, info.inner_rect.width, 10, 20); - assert!(info.inner_rect.x > 0, "sidebar offset should be present"); - assert!(info.inner_rect.y > 0, "tab bar offset should be present"); - - app.handle_mouse(mouse( - MouseEventKind::Moved, - info.inner_rect.x + 2, - info.inner_rect.y + 3, - )); - - assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), - Bytes::from_static(b"\x1b[<35;3;4M") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn dedicated_client_pixel_mouse_preserves_subcell_position() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 18, - 0, - b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.mouse_capture = false; - app.state.host_cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let inner = app.state.view.pane_infos[0].inner_rect; - let geometry = crate::input::mouse::HostGeometry::new(106, 20, 1_060, 400).unwrap(); - let x = u32::from(inner.x + 2) * 10 + 8; - let y = u32::from(inner.y + 3) * 20 + 9; - let report = format!("\x1b[<35;{x};{y}M"); - app.state.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry }); - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .unwrap(); - assert_eq!(runtime.pixel_size(), None); - assert_eq!( - app.state.pane_mouse_position( - runtime, - inner, - mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 3), - ), - Some(crate::input::mouse::Position::Cell { column: 2, row: 3 }) - ); - runtime.resize(inner.height, inner.width, 10, 20); - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .unwrap(); - assert_eq!( - runtime.pixel_size(), - Some((u32::from(inner.width) * 10, u32::from(inner.height) * 20)) - ); - assert_eq!( - app.state.pane_mouse_position( - runtime, - inner, - mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 3), - ), - Some(crate::input::mouse::Position::Pixels { x: 28, y: 69 }) - ); - app.state.host_mouse_pixels = None; - - assert!(app.route_client_pixel_mouse(7, report.as_bytes(), geometry)); - assert_eq!( - input_rx.try_recv().expect("forwarded exact mouse motion"), - Bytes::from_static(b"\x1b[<35;28;69M") - ); - assert!(input_rx.try_recv().is_err()); - assert!(app.state.host_mouse_pixels.is_none()); - } - - #[tokio::test] - async fn mouse_dispatcher_does_not_forward_motion_behind_herdr_modes() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 18, - 0, - b"\x1b[?1003h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.view.pane_infos[0].clone(); - - app.handle_mouse(mouse( - MouseEventKind::Moved, - info.inner_rect.x + 2, - info.inner_rect.y + 3, - )); - - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn unset_right_click_passthrough_keeps_modified_right_click_as_herdr_menu() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - app.state.right_click_passthrough_modifiers = None; - - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse( - MouseEventKind::Down(MouseButton::Right), - info.inner_rect.x + 2, - info.inner_rect.y + 3, - ) - }); - - assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); - assert!(app.state.right_click_passthrough.is_none()); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn pane_right_click_keeps_focus_and_swap_menu_swaps_with_focused_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let source = ws.tabs[0].root_pane; - let target = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(source); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); - let target_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == target) - .expect("target pane info") - .clone(); - let source_rect_before = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == source) - .expect("source pane info") - .rect; - let target_rect_before = target_info.rect; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - target_info.inner_rect.x, - target_info.inner_rect.y, - )); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - let menu = app.state.context_menu.as_mut().expect("pane context menu"); - assert!(matches!( - menu.kind, - ContextMenuKind::Pane { - pane_id, - source_pane_id: Some(source_pane_id), - .. - } if pane_id == target && source_pane_id == source - )); - let swap_idx = menu - .items() - .iter() - .position(|item| *item == "Swap with focused pane") - .expect("swap item"); - menu.list.highlighted = swap_idx; - - handle_context_menu_key( - &mut app.state, - &mut app.terminal_runtimes, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - assert_eq!( - app.state - .view - .pane_infos - .iter() - .find(|info| info.id == source) - .unwrap() - .rect, - target_rect_before - ); - assert_eq!( - app.state - .view - .pane_infos - .iter() - .find(|info| info.id == target) - .unwrap() - .rect, - source_rect_before - ); - } - - #[tokio::test] - async fn normal_right_click_keeps_focus_and_exposes_swap_for_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let source = ws.tabs[0].root_pane; - let target = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(source); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); - let target_info = app - .state - .pane_info_by_id(target) - .expect("target pane info") - .clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - target_info.inner_rect.width, - target_info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - app.state.insert_test_runtime(target, runtime); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - target_info.inner_rect.x, - target_info.inner_rect.y, - )); - - assert!(input_rx.try_recv().is_err()); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - let menu = app.state.context_menu.as_mut().expect("pane context menu"); - assert!(matches!( - menu.kind, - ContextMenuKind::Pane { - pane_id, - source_pane_id: Some(source_pane_id), - .. - } if pane_id == target && source_pane_id == source - )); - assert!(menu.items().contains(&"Swap with focused pane")); - } - - #[tokio::test] - async fn right_click_passthrough_requires_exact_modifier_match() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); - - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y + 3; - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT, - ..mouse(MouseEventKind::Down(MouseButton::Right), col, row) - }); - - assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); - assert!(app.state.right_click_passthrough.is_none()); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn right_click_passthrough_does_not_forward_pane_frame_clicks() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let other_pane = ws.test_split(Direction::Vertical); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("pane info") - .clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - app.state.insert_test_runtime(pane_id, runtime); - app.state.insert_test_runtime( - other_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""), - ); - - assert!(app.state.pane_at(info.rect.x, info.rect.y).is_none()); - assert!(app - .state - .pane_mouse_target(info.rect.x, info.rect.y) - .is_some()); - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse( - MouseEventKind::Down(MouseButton::Right), - info.rect.x, - info.rect.y, - ) - }); - - assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); - assert!(app.state.right_click_passthrough.is_none()); - assert!(input_rx.try_recv().is_err()); - } - - fn sample_worktree_open_state() -> crate::app::state::WorktreeOpenState { - crate::app::state::WorktreeOpenState { - source_workspace_id: "source".into(), - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![ - crate::app::state::WorktreeOpenEntry { - path: "/repo/herdr".into(), - branch: Some("main".into()), - is_linked_worktree: false, - already_open_ws_idx: Some(0), - }, - crate::app::state::WorktreeOpenEntry { - path: "/repo/herdr-issue".into(), - branch: Some("worktree/issue".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }, - ], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - } - } - - #[test] - fn hovering_context_menu_updates_highlight() { - let mut app = app_for_mouse_test(); - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: 0 }, - x: 2, - y: 2, - list: MenuListState::new(0), - }); - app.state.mode = Mode::ContextMenu; - - let menu = app.state.context_menu_rect().unwrap(); - app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); - - assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1); - } - - #[test] - fn clicking_agent_toast_focuses_target_pane() { - let mut app = app_for_mouse_test(); - let active = Workspace::test_new("active"); - let mut background = Workspace::test_new("background"); - let first_pane = background.tabs[0].root_pane; - let target_pane = background.test_split(Direction::Horizontal); - background.tabs[0].layout.focus_pane(first_pane); - - app.state.workspaces = vec![active, background]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - app.state.toast_config.delay_seconds = 0; - let target_terminal_id = app.state.workspaces[1] - .panes - .get(&target_pane) - .unwrap() - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&target_terminal_id) - .unwrap() - .state = AgentState::Working; - - app.state - .handle_app_event(crate::events::AppEvent::StateChanged { - pane_id: target_pane, - agent: Some(Agent::Pi), - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: std::time::Instant::now(), - }); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let hit = app.state.view.toast_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - hit.x + 1, - hit.y + 1, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target_pane)); - assert!(app.state.toast.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - - app.state.last_pane(); - - assert_eq!(app.state.active, Some(0)); - assert_eq!( - app.state.workspaces[0].focused_pane_id(), - Some(app.state.workspaces[0].tabs[0].root_pane) - ); - } - - #[test] - fn toast_click_does_not_steal_mouse_from_settings_overlay() { - let mut app = app_for_mouse_test(); - let active = Workspace::test_new("active"); - let background = Workspace::test_new("background"); - let target_pane = background.tabs[0].root_pane; - let workspace_id = background.id.clone(); - - app.state.workspaces = vec![active, background]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "pi finished".into(), - context: "background · 2".into(), - position: None, - target: Some(crate::app::state::ToastTarget { - workspace_id, - pane_id: target_pane, - }), - }); - app.state.mode = Mode::Settings; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let hit = app.state.view.toast_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - hit.x + 1, - hit.y + 1, - )); - - assert_eq!(app.state.active, Some(0)); - assert!(app.state.toast.is_some()); - } - - #[test] - fn clicking_confirm_close_accepts_workspace_close() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 1; - app.state.begin_workspace_close_confirmation(1); - - let popup = app.state.confirm_close_rect(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (confirm, _) = crate::ui::confirm_close_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - confirm.x, - confirm.y, - )); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn clicking_rename_save_submits_workspace_rename_through_api_path() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("old")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameWorkspace; - app.state.name_input = "new".into(); - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 24)); - let inner = app.state.rename_modal_inner().unwrap(); - let (save, _, _) = crate::ui::rename_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - save.x, - save.y, - )); - - assert_eq!(app.state.workspaces[0].custom_name.as_deref(), Some("new")); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::WorkspaceRenamed) - })); - } - - #[test] - fn clicking_open_worktree_row_selects_and_requests_open() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(sample_worktree_open_state()); - let inner = - crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - inner.x + 1, - inner.y + 5, - )); - - assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1); - assert!(app.state.request_submit_worktree_open); - } - - #[test] - fn clicking_open_worktree_buttons_requests_open_or_cancels() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(sample_worktree_open_state()); - let inner = - crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); - let (open, _) = crate::ui::open_existing_worktree_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - open.x, - open.y, - )); - - assert!(app.state.worktree_open.is_some()); - assert!(app.state.request_submit_worktree_open); - - let mut app = app_for_mouse_test(); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(sample_worktree_open_state()); - let inner = - crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); - let (_, cancel) = crate::ui::open_existing_worktree_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - cancel.x, - cancel.y, - )); - - assert!(app.state.worktree_open.is_none()); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn scrolling_open_worktree_picker_moves_selection() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(sample_worktree_open_state()); - - app.handle_mouse(mouse(MouseEventKind::ScrollDown, 1, 1)); - assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1); - - app.handle_mouse(mouse(MouseEventKind::ScrollUp, 1, 1)); - assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 0); - } - - #[test] - fn clicking_remove_worktree_buttons_requests_remove_or_cancels() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::ConfirmRemoveWorktree; - app.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState { - workspace_id: "issue".into(), - repo_root: "/repo/herdr".into(), - path: "/repo/herdr-issue".into(), - error: None, - removing: false, - force_confirmation: false, - }); - let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (remove, _) = crate::ui::remove_worktree_button_rects(inner, false); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - remove.x, - remove.y, - )); - - assert!(app.state.worktree_remove.is_some()); - assert!(app.state.request_submit_worktree_remove); - - let mut app = app_for_mouse_test(); - app.state.mode = Mode::ConfirmRemoveWorktree; - app.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState { - workspace_id: "issue".into(), - repo_root: "/repo/herdr".into(), - path: "/repo/herdr-issue".into(), - error: None, - removing: false, - force_confirmation: false, - }); - let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (_, cancel) = crate::ui::remove_worktree_button_rects(inner, false); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - cancel.x, - cancel.y, - )); - - assert!(app.state.worktree_remove.is_none()); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn clicking_confirm_close_accepts_after_workspace_context_menu_close() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: 1 }, - x: 2, - y: 2, - list: MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - handle_context_menu_key( - &mut app.state, - &mut app.terminal_runtimes, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.selected, 1); - - let popup = app.state.confirm_close_rect(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (confirm, _) = crate::ui::confirm_close_button_rects(inner); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - confirm.x + 1, - confirm.y, - )); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "a"); - } - - #[test] - fn clicking_context_menu_close_routes_through_api_path() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.confirm_close = false; - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: 1 }, - x: 2, - y: 2, - list: MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - - let menu = app.state.context_menu_rect().unwrap(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 2, - )); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "a"); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::WorkspaceClosed) - })); - } - - #[cfg(unix)] - #[tokio::test] - async fn keyboard_context_menu_split_keeps_new_runtime() { - let mut app = app_for_mouse_test(); - app.state.default_shell = "/usr/bin/true".into(); - let (workspace, terminal, runtime) = Workspace::new( - std::env::current_dir().unwrap_or_else(|_| "/".into()), - 24, - 80, - app.state.pane_scrollback_limit_bytes, - app.state.host_terminal_theme, - app.state.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), - app.event_tx.clone(), - app.render_notify.clone(), - app.render_dirty.clone(), - ) - .expect("workspace should spawn"); - app.state.workspaces = vec![workspace]; - app.terminal_runtimes.insert(terminal.id.clone(), runtime); - app.state.terminals.insert(terminal.id.clone(), terminal); - app.state.active = Some(0); - app.state.selected = 0; - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let runtime_count = app.terminal_runtimes.len(); - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx: 0, - tab_idx: 0, - pane_id, - source_pane_id: None, - has_manual_label: false, - right_click_passthrough: false, - }, - x: 2, - y: 2, - list: MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - - handle_context_menu_key( - &mut app.state, - &mut app.terminal_runtimes, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert_eq!(app.terminal_runtimes.len(), runtime_count + 1); - - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } - } - - #[test] - fn dragging_pane_split_updates_captured_layout_ratio() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let before = capture_snapshot(&app.state); - let drag_row = border.area.y.saturating_add(1); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - border.pos, - drag_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - border.pos.saturating_add(6), - drag_row, - )); - - let after = capture_snapshot(&app.state); - assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); - } - - #[test] - fn pane_split_hitbox_does_not_overlap_right_pane_content() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_gaps = false; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - - assert!(app - .state - .find_border_at(border.pos.saturating_sub(1), row) - .is_none()); - assert!(app.state.find_border_at(border.pos, row).is_some()); - assert!(app - .state - .find_border_at(border.pos.saturating_add(1), row) - .is_none()); - } - - #[test] - fn pane_split_hitbox_does_not_overlap_bottom_pane_content() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_gaps = false; - app.state.workspaces[0].test_split(Direction::Vertical); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let col = border.area.x.saturating_add(1); - - assert!(app - .state - .find_border_at(col, border.pos.saturating_sub(1)) - .is_none()); - assert!(app.state.find_border_at(col, border.pos).is_some()); - assert!(app - .state - .find_border_at(col, border.pos.saturating_add(1)) - .is_none()); - } - - #[test] - fn borderless_no_gap_split_has_no_mouse_hitbox_over_content() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_borders = false; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - - assert!(app.state.find_border_at(border.pos, row).is_none()); - } - - #[test] - fn bordered_pane_gaps_keep_both_split_borders_draggable() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_gaps = true; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - - assert!(app - .state - .find_border_at(border.pos.saturating_sub(1), row) - .is_some()); - assert!(app.state.find_border_at(border.pos, row).is_some()); - assert!(app - .state - .find_border_at(border.pos.saturating_add(1), row) - .is_none()); - } - - #[test] - fn borderless_pane_gap_is_not_a_pane_but_remains_split_draggable() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_borders = false; - app.state.pane_gaps = true; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - let gap_col = border.pos.saturating_sub(1); - - assert!(app.state.pane_at(gap_col, row).is_none()); - assert!(app.state.find_border_at(gap_col, row).is_some()); - assert!(app.state.find_border_at(border.pos, row).is_none()); - } - - #[test] - fn borderless_gap_hitbox_is_empty_when_first_split_side_has_one_cell() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_borders = false; - app.state.pane_gaps = true; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 2, 4)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - let candidate_gap_col = border.pos.saturating_sub(1); - - assert!(app.state.pane_frame_at(candidate_gap_col, row).is_some()); - assert!(app.state.find_border_at(candidate_gap_col, row).is_none()); - } - - #[test] - fn borderless_gap_hitbox_is_empty_when_first_split_side_has_zero_width() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_borders = false; - app.state.pane_gaps = true; - app.state.workspaces[0].test_split(Direction::Horizontal); - app.state.workspaces[0].tabs[0] - .layout - .set_ratio_at(&[], 0.1); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 1, 4)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - - assert_eq!(border.pos, 0); - assert!(app.state.find_border_at(0, row).is_none()); - } - - #[test] - fn selecting_from_right_pane_first_content_column_starts_selection() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let second_pane = ws.test_split(Direction::Horizontal); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let second_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info") - .clone(); - let col = second_info.inner_rect.x; - let row = second_info.inner_rect.y; - - assert!(app.state.find_border_at(col, row).is_none()); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - - assert!(app.state.drag.is_none()); - assert_eq!( - app.state - .selection - .as_ref() - .map(|selection| selection.pane_id), - Some(second_pane) - ); - } - - #[test] - fn selecting_from_bottom_pane_first_content_row_starts_selection() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let second_pane = ws.test_split(Direction::Vertical); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let second_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info") - .clone(); - let col = second_info.inner_rect.x; - let row = second_info.inner_rect.y; - - assert!(app.state.find_border_at(col, row).is_none()); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - - assert!(app.state.drag.is_none()); - assert_eq!( - app.state - .selection - .as_ref() - .map(|selection| selection.pane_id), - Some(second_pane) - ); - } - - #[tokio::test] - async fn dragging_vertical_pane_split_still_resizes_when_pane_mouse_reporting_is_enabled() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(Direction::Vertical); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let pane_infos = app.state.view.pane_infos.clone(); - let first_info = pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info") - .clone(); - let second_info = pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info") - .clone(); - - app.state.insert_test_runtime( - first_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - first_info.inner_rect.width.max(1), - first_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - app.state.insert_test_runtime( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - second_info.inner_rect.width.max(1), - second_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app - .state - .view - .split_borders - .iter() - .find(|border| border.direction == Direction::Vertical) - .expect("vertical split border") - .clone(); - let before = capture_snapshot(&app.state); - let drag_col = border.area.x.saturating_add(1); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - drag_col, - border.pos, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - drag_col, - border.pos.saturating_add(4), - )); - - let after = capture_snapshot(&app.state); - assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); - } - - #[tokio::test] - async fn dragging_horizontal_pane_split_still_resizes_when_pane_mouse_reporting_is_enabled() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(Direction::Horizontal); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let pane_infos = app.state.view.pane_infos.clone(); - let first_info = pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info") - .clone(); - let second_info = pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info") - .clone(); - - app.state.insert_test_runtime( - first_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - first_info.inner_rect.width.max(1), - first_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - app.state.insert_test_runtime( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - second_info.inner_rect.width.max(1), - second_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app - .state - .view - .split_borders - .iter() - .find(|border| border.direction == Direction::Horizontal) - .expect("horizontal split border") - .clone(); - let before = capture_snapshot(&app.state); - let drag_row = border.area.y.saturating_add(1); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - border.pos, - drag_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - border.pos.saturating_add(6), - drag_row, - )); - - let after = capture_snapshot(&app.state); - assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); - } - - #[test] - fn wheel_routing_prefers_mouse_reporting() { - let input_state = crate::pane::InputState { - alternate_screen: true, - application_cursor: false, - bracketed_paste: false, - focus_reporting: false, - mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, - mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, - mouse_alternate_scroll: true, - modify_other_keys: false, - color_scheme_reporting: false, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport); - } - - #[test] - fn wheel_over_tab_bar_switches_tabs() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - ws.test_add_tab(Some("three")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let tab_bar = app.state.view.tab_bar_rect; - - app.handle_mouse(mouse(MouseEventKind::ScrollDown, tab_bar.x + 1, tab_bar.y)); - assert_eq!(app.state.workspaces[0].active_tab, 1); - - app.handle_mouse(mouse(MouseEventKind::ScrollUp, tab_bar.x + 1, tab_bar.y)); - assert_eq!(app.state.workspaces[0].active_tab, 0); - - app.handle_mouse(mouse(MouseEventKind::ScrollUp, tab_bar.x + 1, tab_bar.y)); - assert_eq!(app.state.workspaces[0].active_tab, 2); - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - tab_bar.x + tab_bar.width.saturating_sub(1), - tab_bar.y, - )); - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - #[test] - fn bottom_mode_bar_consumes_hidden_tab_mouse_actions() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Prefix; - app.state.tab_bar_position = crate::config::TabBarPositionConfig::Bottom; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let second_tab = app.state.view.tab_hit_areas[1]; - let new_tab = app.state.view.new_tab_hit_area; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_tab.x, - new_tab.y, - )); - - app.state.drag = Some(DragState { - target: DragTarget::SidebarDivider, - }); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x, - second_tab.y, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - assert_eq!(app.state.workspaces[0].tabs.len(), 2); - assert!(app.state.context_menu.is_none()); - assert!(app.state.tab_presses.is_empty()); - assert!(app.state.drag.is_none()); - } - - #[test] - fn right_click_inactive_tab_opens_menu_without_switching_tabs() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let second_tab = app.state.view.tab_hit_areas[1]; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - second_tab.x + 1, - second_tab.y, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - let menu = app.state.context_menu.as_ref().expect("tab context menu"); - assert_eq!( - menu.kind, - ContextMenuKind::Tab { - ws_idx: 0, - tab_idx: 1 - } - ); - assert_eq!(app.state.mode, Mode::ContextMenu); - } - - #[test] - fn clicking_tab_context_menu_close_leaves_context_menu_mode() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let second_tab = app.state.view.tab_hit_areas[1]; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - second_tab.x + 1, - second_tab.y, - )); - - let menu = app - .state - .context_menu_rect() - .expect("tab context menu rect"); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 3, - )); - - assert_eq!(app.state.workspaces[0].tabs.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "one"); - assert!(app.state.context_menu.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app - .event_hub - .events_after(0) - .iter() - .any(|(_, event)| { matches!(event.event, crate::api::schema::EventKind::TabClosed) })); - } - - #[test] - fn clicking_pane_context_menu_close_leaves_context_menu_mode() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(second_pane); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let first_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info") - .clone(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - first_info.inner_rect.x + 1, - first_info.inner_rect.y + 1, - )); - - let menu_state = app.state.context_menu.as_ref().expect("pane context menu"); - let close_idx = menu_state - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane menu item"); - let menu = app - .state - .context_menu_rect() - .expect("pane context menu rect"); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 1 + close_idx as u16, - )); - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert!(app.state.context_menu.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::PaneClosed) - })); - } - - #[test] - fn clicking_pane_context_menu_close_last_parent_group_pane_keeps_confirmation_mode() { - let mut app = app_for_mouse_test(); - let mut parent = Workspace::test_new("main"); - let pane_id = parent.tabs[0].root_pane; - mark_worktree_space_member(&mut parent, 0, "repo-key"); - let mut child = Workspace::test_new("issue"); - mark_worktree_space_member(&mut child, 1, "repo-key"); - app.state.workspaces = vec![parent, child]; - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let pane_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("pane info") - .clone(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - pane_info.inner_rect.x + 1, - pane_info.inner_rect.y + 1, - )); - - let menu_state = app.state.context_menu.as_ref().expect("pane context menu"); - let close_idx = menu_state - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane menu item"); - let menu = app - .state - .context_menu_rect() - .expect("pane context menu rect"); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 1 + close_idx as u16, - )); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - assert!(app.state.context_menu.is_none()); - } - - #[test] - fn wheel_over_overflowing_tab_bar_switches_tabs() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.tabs[0].set_custom_name("very-long-one".into()); - ws.test_add_tab(Some("very-long-two")); - ws.test_add_tab(Some("very-long-three")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20)); - assert!(app.state.view.tab_scroll_right_hit_area.width > 0); - let tab_bar = app.state.view.tab_bar_rect; - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - tab_bar.x + tab_bar.width.saturating_sub(2), - tab_bar.y, - )); - assert_eq!(app.state.workspaces[0].active_tab, 1); - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - tab_bar.x + tab_bar.width.saturating_sub(2), - tab_bar.y, - )); - assert_eq!(app.state.workspaces[0].active_tab, 2); - } - - #[test] - fn wheel_outside_tab_bar_does_not_switch_tabs() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let terminal = app.state.view.terminal_area; - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - terminal.x + 1, - terminal.y + 1, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - #[test] - fn mobile_switch_button_opens_switcher_and_workspace_row_switches_workspace() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - assert_eq!(app.state.view.layout, ViewLayout::Mobile); - - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - - assert_eq!(app.state.mode, Mode::Navigate); - - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 4, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn mobile_workspace_panel_scroll_reaches_extra_workspaces() { - let mut app = app_for_mouse_test(); - app.state.workspaces = (0..12) - .map(|idx| Workspace::test_new(&format!("ws-{idx}"))) - .collect(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - assert_eq!(app.state.mode, Mode::Navigate); - - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - viewport.x + 2, - viewport.y, - )); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - assert_eq!(app.state.mobile_switcher_scroll, 2); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 2, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn mobile_global_scroll_reaches_tabs_and_switches_tab() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - ws.test_add_tab(Some("three")); - ws.test_add_tab(Some("four")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 12)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - viewport.x + 2, - viewport.y, - )); - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - viewport.x + 2, - viewport.y, - )); - assert_eq!(app.state.mobile_switcher_scroll, 4); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 4, - )); - assert_eq!(app.state.workspaces[0].active_tab, 2); - } - - #[test] - fn mobile_switcher_new_workspace_opens_prompt_when_enabled() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prompt_new_workspace_name = true; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 1, - )); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert!(app.state.pending_workspace_create_cwd.is_some()); - assert!(app.state.name_input_replace_on_type); - assert_eq!(app.state.workspaces.len(), 1); - } - - #[test] - fn desktop_new_workspace_opens_prompt_when_enabled() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prompt_new_workspace_name = true; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); - let new_workspace = app.state.sidebar_new_button_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_workspace.x + 1, - new_workspace.y, - )); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert!(app.state.pending_workspace_create_cwd.is_some()); - assert!(app.state.name_input_replace_on_type); - assert_eq!(app.state.workspaces.len(), 1); - } - - #[tokio::test] - async fn desktop_new_workspace_creates_immediately_by_default() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); - let new_workspace = app.state.sidebar_new_button_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_workspace.x + 1, - new_workspace.y, - )); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.pending_workspace_create_cwd.is_none()); - crate::app::api::test_support::shutdown_test_runtimes(&mut app); - } - - #[test] - fn mobile_switcher_new_tab_opens_dialog_when_enabled() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("logs")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 5, - )); - - assert_eq!(app.state.mode, Mode::RenameTab); - assert!(app.state.creating_new_tab); - } - - #[test] - fn mobile_switcher_new_tab_skips_dialog_when_prompt_disabled() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("logs")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prompt_new_tab_name = false; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 5, - )); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(!app.state.creating_new_tab); - assert!(app.state.request_new_tab); - assert!(app.state.requested_new_tab_name.is_none()); - } - - #[test] - fn desktop_new_tab_button_skips_dialog_when_prompt_disabled() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prompt_new_tab_name = false; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); - let new_tab_area = app.state.view.new_tab_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_tab_area.x + 1, - new_tab_area.y, - )); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(!app.state.creating_new_tab); - assert!(app.state.request_new_tab); - assert!(app.state.requested_new_tab_name.is_none()); - } - - #[test] - fn mobile_switcher_swallows_non_left_mouse_events() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - assert_eq!(app.state.mode, Mode::Navigate); - - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - viewport.x + 2, - viewport.y + 2, - )); - - assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.state.context_menu.is_none()); - } - - #[test] - fn mobile_switch_button_does_not_bypass_rename_modal() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameTab; - app.state.creating_new_tab = true; - app.state.name_input = "new tab".into(); - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(!app.state.creating_new_tab); - assert!(!app.state.request_new_tab); - } - - #[test] - fn mobile_switcher_close_returns_to_terminal() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - assert_eq!(app.state.mode, Mode::Navigate); - - let close = crate::ui::mobile_switcher_areas(&app.state).close; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - close.x + 1, - close.y, - )); - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() { - let input_state = crate::pane::InputState { - alternate_screen: true, - application_cursor: false, - bracketed_paste: false, - focus_reporting: false, - mouse_protocol_mode: crate::input::MouseProtocolMode::None, - mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, - mouse_alternate_scroll: true, - modify_other_keys: false, - color_scheme_reporting: false, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll); - } - - #[test] - fn wheel_routing_falls_back_to_host_scrollback() { - let input_state = crate::pane::InputState { - alternate_screen: false, - application_cursor: false, - bracketed_paste: false, - focus_reporting: false, - mouse_protocol_mode: crate::input::MouseProtocolMode::None, - mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, - mouse_alternate_scroll: true, - modify_other_keys: false, - color_scheme_reporting: false, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll); - } -} diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs deleted file mode 100644 index ab6627f6..00000000 --- a/src/app/input/navigate.rs +++ /dev/null @@ -1,3700 +0,0 @@ -use std::{ - fs, io, - io::Write, - process::Stdio, - time::{SystemTime, UNIX_EPOCH}, -}; - -use bytes::Bytes; -use crossterm::event::KeyCode; -#[cfg(test)] -use crossterm::event::KeyEvent; -use ratatui::layout::Direction; - -use crate::{ - app::{ - state::{AppState, Mode}, - App, - }, - input::{ - KeybindAction as NavigateAction, KeybindDispatch as BindingDispatch, - KeybindMatch as PrefixBindingMatch, TerminalKey, - }, - layout::NavDirection, - terminal::TerminalRuntimeRegistry, -}; - -#[cfg(test)] -pub(crate) fn terminal_direct_navigation_action( - state: &AppState, - key: TerminalKey, -) -> Option { - action_for_key(state, key, BindingDispatch::Direct) -} - -pub(crate) fn terminal_direct_non_indexed_navigation_action( - state: &AppState, - key: &TerminalKey, -) -> Option { - non_indexed_action_for_key(state, key, BindingDispatch::Direct) -} - -pub(crate) fn terminal_direct_indexed_navigation_action( - state: &AppState, - key: &TerminalKey, -) -> Option { - indexed_navigation_action(state, key, BindingDispatch::Direct) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ActionContext { - Direct, - Prefix, - Navigate, -} - -impl App { - fn cancel_copy_mode_if_active(&mut self) { - if self.state.copy_mode.is_some() { - self.state.cancel_copy_mode(&self.terminal_runtimes); - } - } - - pub(crate) fn handle_prefix_key(&mut self, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - self.state.update_dismissed = true; - - if matches!(key.code, KeyCode::Modifier(_)) { - return; - } - - if self.state.is_prefix_key(&raw_key) { - if self.state.copy_mode_pane_is_focused() { - self.state.cancel_copy_mode(&self.terminal_runtimes); - } - if !self.pass_through_key_to_focused_pane(raw_key) { - leave_command_mode(&mut self.state); - } - return; - } - - if key.code == KeyCode::Esc { - leave_command_mode(&mut self.state); - return; - } - - match prefix_binding_for_key(&self.state, &raw_key) { - Some(PrefixBindingMatch::Action(action)) => self.execute_prefix_key_action(action), - Some(PrefixBindingMatch::Command(binding)) => { - self.cancel_copy_mode_if_active(); - self.launch_custom_command(binding, ActionContext::Prefix); - } - None => leave_command_mode(&mut self.state), - } - } - - fn execute_prefix_key_action(&mut self, action: NavigateAction) { - if action == NavigateAction::EditScrollback { - let previous_mode = self.state.mode; - self.cancel_copy_mode_if_active(); - self.launch_focused_scrollback_editor(); - finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode); - } else if action == NavigateAction::CopyMode { - self.cancel_copy_mode_if_active(); - self.execute_tui_navigate_action(action, ActionContext::Prefix); - } else if copy_mode_survives_prefix_action(action) { - self.execute_tui_navigate_action(action, ActionContext::Prefix); - if self.state.copy_mode.is_some() { - self.state.sync_copy_mode_with_focus(); - } - } else { - self.cancel_copy_mode_if_active(); - self.execute_tui_navigate_action(action, ActionContext::Prefix); - } - self.selection_autoscroll_deadline = None; - } - - pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - self.state.update_dismissed = true; - - if key.code == KeyCode::Esc || self.state.is_prefix_key(&raw_key) { - leave_navigate_mode(&mut self.state); - return; - } - - if self - .state - .keybinds - .navigate - .workspace_up - .matches_direct_key(&raw_key) - { - self.state.move_selected_workspace_by_visible_delta(-1); - return; - } - if self - .state - .keybinds - .navigate - .workspace_down - .matches_direct_key(&raw_key) - { - self.state.move_selected_workspace_by_visible_delta(1); - return; - } - - if let Some(action) = navigate_reserved_action_for_key(&self.state, &raw_key) { - self.execute_tui_navigate_action(action, ActionContext::Navigate); - return; - } - - if let Some(action) = navigate_mode_non_indexed_action_for_key(&self.state, &raw_key) { - if action == NavigateAction::EditScrollback { - self.launch_focused_scrollback_editor(); - } else { - self.execute_tui_navigate_action(action, ActionContext::Navigate); - } - self.selection_autoscroll_deadline = None; - return; - } - - if let Some(binding) = command_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { - self.launch_custom_command(binding, ActionContext::Navigate); - return; - } - - if let Some(action) = navigate_mode_indexed_action_for_key(&self.state, &raw_key) { - self.execute_tui_navigate_action(action, ActionContext::Navigate); - self.selection_autoscroll_deadline = None; - } - } - - pub(super) fn execute_tui_navigate_action( - &mut self, - action: NavigateAction, - context: ActionContext, - ) { - let previous_mode = self.state.mode; - match action { - NavigateAction::NewWorkspace => { - self.begin_tui_workspace_create("tui.key.workspace.create"); - } - NavigateAction::NewWorktree => { - if let Some(ws_idx) = workspace_action_target(&self.state, context).filter(|idx| { - workspace_can_start_worktree_action(&self.state, &self.terminal_runtimes, *idx) - }) { - self.state.request_new_linked_worktree = Some(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::OpenWorktree => { - if let Some(ws_idx) = workspace_action_target(&self.state, context).filter(|idx| { - workspace_can_start_worktree_action(&self.state, &self.terminal_runtimes, *idx) - }) { - self.state.request_open_existing_worktree = Some(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::RemoveWorktree => { - if let Some(ws_idx) = workspace_action_target(&self.state, context) { - self.state.request_remove_linked_worktree = Some(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::RenameWorkspace => { - if let Some(ws_idx) = workspace_action_target(&self.state, context) { - super::modal::open_rename_workspace( - &mut self.state, - &self.terminal_runtimes, - ws_idx, - ); - } - } - NavigateAction::CloseWorkspace => { - if let Some(ws_idx) = workspace_action_target(&self.state, context) { - self.state.selected = ws_idx; - if self.state.confirm_close { - super::modal::open_confirm_close(&mut self.state); - } else { - self.close_workspace_idx_with_group_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - } - NavigateAction::SwitchWorkspace(idx) => { - if let Some(ws_idx) = self.state.workspace_at_visible_position(idx) { - self.focus_workspace_idx_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::SwitchTab(idx) => { - if self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get(ws_idx)) - .is_some_and(|ws| idx < ws.tabs.len()) - { - self.focus_tab_idx_via_api(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::FocusAgent(idx) => { - if let Some((ws_idx, pane_id)) = self.agent_entry_target(idx) { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.state.ensure_agent_panel_entry_visible(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::WorkspacePicker => { - self.state.mobile_switcher_scroll = 0; - self.state.mode = Mode::Navigate; - } - NavigateAction::PreviousWorkspace => { - if let Some(ws_idx) = self.relative_visible_workspace(-1) { - self.focus_workspace_idx_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NextWorkspace => { - if let Some(ws_idx) = self.relative_visible_workspace(1) { - self.focus_workspace_idx_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::PreviousAgent => { - if let Some((idx, ws_idx, pane_id)) = self.relative_agent_entry(false) { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.state.ensure_agent_panel_entry_visible(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NextAgent => { - if let Some((idx, ws_idx, pane_id)) = self.relative_agent_entry(true) { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.state.ensure_agent_panel_entry_visible(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NewTab => { - if self.state.active.is_some() { - if self.state.prompt_new_tab_name { - super::modal::open_new_tab_dialog(&mut self.state); - } else { - self.runtime_tab_create( - "tui.key.tab.create", - crate::api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label: None, - env: Default::default(), - }, - ); - leave_navigate_mode(&mut self.state); - } - } - } - NavigateAction::RenameTab => { - super::modal::open_rename_active_tab(&mut self.state, false) - } - NavigateAction::PreviousTab => { - if let Some(tab_idx) = self.relative_tab(-1) { - self.focus_tab_idx_via_api(tab_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NextTab => { - if let Some(tab_idx) = self.relative_tab(1) { - self.focus_tab_idx_via_api(tab_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::MoveTabPrevious => { - if let Some((ws_idx, source, insert)) = self.active_tab_move(-1) { - self.move_tab_via_api(ws_idx, source, insert); - } - leave_navigate_mode(&mut self.state); - } - NavigateAction::MoveTabNext => { - if let Some((ws_idx, source, insert)) = self.active_tab_move(1) { - self.move_tab_via_api(ws_idx, source, insert); - } - leave_navigate_mode(&mut self.state); - } - NavigateAction::CloseTab => { - if !self.close_active_tab_via_api_requires_confirmation() { - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::RenamePane => { - if let Some(pane_id) = self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get(ws_idx)) - .and_then(|ws| ws.focused_pane_id()) - { - super::modal::open_rename_pane(&mut self.state, pane_id); - } - } - NavigateAction::FocusPaneLeft => { - self.focus_pane_direction_in_context(NavDirection::Left, context) - } - NavigateAction::FocusPaneDown => { - self.focus_pane_direction_in_context(NavDirection::Down, context) - } - NavigateAction::FocusPaneUp => { - self.focus_pane_direction_in_context(NavDirection::Up, context) - } - NavigateAction::FocusPaneRight => { - self.focus_pane_direction_in_context(NavDirection::Right, context) - } - NavigateAction::SwapPaneLeft => { - self.swap_pane_direction_via_api(NavDirection::Left); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SwapPaneDown => { - self.swap_pane_direction_via_api(NavDirection::Down); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SwapPaneUp => { - self.swap_pane_direction_via_api(NavDirection::Up); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SwapPaneRight => { - self.swap_pane_direction_via_api(NavDirection::Right); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SplitVertical => { - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Right); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SplitHorizontal => { - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Down); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ClosePane => { - if !self.close_focused_pane_via_api_requires_confirmation() { - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::EditScrollback => {} - NavigateAction::CopyMode => self.state.enter_copy_mode(&self.terminal_runtimes), - NavigateAction::Zoom => { - self.zoom_focused_pane_via_api(); - leave_navigate_mode(&mut self.state); - } - NavigateAction::EnterResizeMode => self.state.mode = Mode::Resize, - NavigateAction::ResizePaneLeft => { - self.resize_pane_direction_via_api(NavDirection::Left); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ResizePaneDown => { - self.resize_pane_direction_via_api(NavDirection::Down); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ResizePaneUp => { - self.resize_pane_direction_via_api(NavDirection::Up); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ResizePaneRight => { - self.resize_pane_direction_via_api(NavDirection::Right); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ToggleSidebar => { - self.state.sidebar_collapsed = !self.state.sidebar_collapsed; - leave_navigate_mode(&mut self.state); - } - NavigateAction::CyclePaneNext => { - self.cycle_pane_via_api(false); - leave_navigate_mode(&mut self.state); - } - NavigateAction::CyclePanePrevious => { - self.cycle_pane_via_api(true); - leave_navigate_mode(&mut self.state); - } - NavigateAction::LastPane => { - self.last_pane_via_api(); - leave_navigate_mode(&mut self.state); - } - NavigateAction::Help => super::modal::open_keybind_help(&mut self.state), - NavigateAction::Settings => super::settings::open_settings(&mut self.state), - NavigateAction::ReloadConfig => { - self.runtime_server_reload_config("tui.server.reload_config"); - leave_navigate_mode(&mut self.state); - } - NavigateAction::OpenNotificationTarget => { - self.focus_toast_target_via_api(); - if self.state.mode == Mode::Navigate { - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::Detach => { - super::modal::request_detach(&mut self.state); - leave_navigate_mode(&mut self.state); - } - NavigateAction::OpenNavigator => { - self.state.open_navigator_from(&self.terminal_runtimes) - } - } - - finish_action_context(&mut self.state, context, previous_mode); - } - - pub(crate) fn focus_workspace_idx_via_api(&mut self, ws_idx: usize) { - let workspace_id = self.public_workspace_id(ws_idx); - self.runtime_workspace_focus("tui.workspace.focus", workspace_id); - } - - pub(crate) fn close_workspace_idx_with_group_via_api(&mut self, ws_idx: usize) { - let workspace_id = self.public_workspace_id(ws_idx); - self.runtime_workspace_close_group("tui.workspace.close", workspace_id); - } - - pub(crate) fn move_workspace_via_api(&mut self, source_ws_idx: usize, insert_idx: usize) { - let workspace_id = self.public_workspace_id(source_ws_idx); - self.runtime_workspace_move( - "tui.workspace.move", - crate::api::schema::WorkspaceMoveParams { - workspace_id, - insert_index: insert_idx, - }, - ); - } - - pub(crate) fn move_workspace_block_via_api( - &mut self, - params: crate::api::schema::WorkspaceMoveBlockParams, - ) { - self.runtime_workspace_move_block("tui.workspace.move_block", params); - } - - pub(crate) fn focus_tab_idx_via_api(&mut self, tab_idx: usize) { - let Some(ws_idx) = self.state.active else { - return; - }; - let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) else { - return; - }; - self.runtime_tab_focus("tui.tab.focus", tab_id); - } - - pub(crate) fn close_active_tab_via_api_requires_confirmation(&mut self) -> bool { - let Some(ws_idx) = self.state.active else { - return false; - }; - if self - .state - .workspaces - .get(ws_idx) - .is_some_and(|ws| ws.tabs.len() <= 1) - { - if self.state.confirm_implicit_worktree_group_close(ws_idx) { - return true; - } - self.close_workspace_idx_with_group_via_api(ws_idx); - return false; - } - let tab_idx = self.state.workspaces[ws_idx].active_tab_index(); - let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) else { - return false; - }; - self.runtime_tab_close("tui.tab.close", tab_id); - false - } - - pub(crate) fn move_tab_via_api( - &mut self, - ws_idx: usize, - source_tab_idx: usize, - insert_idx: usize, - ) { - let Some(tab_id) = self.public_tab_id(ws_idx, source_tab_idx) else { - return; - }; - self.runtime_tab_move( - "tui.tab.move", - crate::api::schema::TabMoveParams { - tab_id, - insert_index: insert_idx, - }, - ); - } - - pub(crate) fn focus_pane_internal_via_api( - &mut self, - ws_idx: usize, - pane_id: crate::layout::PaneId, - ) { - let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) else { - return; - }; - self.runtime_pane_focus("tui.pane.focus", pane_id); - } - - pub(crate) fn focus_pane_direction_via_api(&mut self, direction: NavDirection) { - if let Some((ws_idx, target)) = self.directional_pane_target_from_view(direction) { - self.focus_pane_internal_via_api(ws_idx, target); - return; - } - self.runtime_pane_focus_direction( - "tui.pane.focus_direction", - crate::api::schema::PaneFocusDirectionParams { - pane_id: None, - direction: api_pane_direction(direction), - }, - ); - } - - fn focus_pane_direction_in_context(&mut self, direction: NavDirection, context: ActionContext) { - let preserve_navigate_mode = - context == ActionContext::Navigate && self.state.mode == Mode::Navigate; - self.focus_pane_direction_via_api(direction); - if preserve_navigate_mode { - self.state.mode = Mode::Navigate; - } - } - - pub(crate) fn resize_pane_direction_via_api(&mut self, direction: NavDirection) { - self.runtime_pane_resize( - "tui.pane.resize", - crate::api::schema::PaneResizeParams { - pane_id: None, - direction: api_pane_direction(direction), - amount: None, - }, - ); - } - - pub(crate) fn swap_pane_direction_via_api(&mut self, direction: NavDirection) { - if let Some((ws_idx, source, target)) = self.directional_pane_swap_from_view(direction) { - let source_pane_id = self.public_pane_id(ws_idx, source); - let target_pane_id = self.public_pane_id(ws_idx, target); - if let (Some(source_pane_id), Some(target_pane_id)) = (source_pane_id, target_pane_id) { - self.runtime_pane_swap( - "tui.pane.swap_exact", - crate::api::schema::PaneSwapParams { - pane_id: None, - direction: None, - source_pane_id: Some(source_pane_id), - target_pane_id: Some(target_pane_id), - }, - ); - return; - } - } - self.runtime_pane_swap( - "tui.pane.swap", - crate::api::schema::PaneSwapParams { - pane_id: None, - direction: Some(api_pane_direction(direction)), - source_pane_id: None, - target_pane_id: None, - }, - ); - } - - pub(crate) fn split_focused_pane_via_api( - &mut self, - direction: crate::api::schema::SplitDirection, - ) { - self.runtime_pane_split( - "tui.pane.split", - crate::api::schema::PaneSplitParams { - workspace_id: None, - target_pane_id: None, - direction, - ratio: None, - cwd: None, - focus: true, - right_click: Default::default(), - env: Default::default(), - }, - ); - } - - pub(crate) fn close_focused_pane_via_api_requires_confirmation(&mut self) -> bool { - let Some((ws_idx, pane_id)) = self.focused_pane_target() else { - return false; - }; - let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) else { - return false; - }; - self.runtime_pane_close("tui.pane.close", pane_id); - self.state.mode == Mode::ConfirmClose - } - - pub(crate) fn zoom_focused_pane_via_api(&mut self) { - self.runtime_pane_zoom( - "tui.pane.zoom", - crate::api::schema::PaneZoomParams { - pane_id: None, - mode: crate::api::schema::PaneZoomMode::Toggle, - }, - ); - } - - pub(crate) fn set_split_ratio_via_api(&mut self, path: Vec, ratio: f32) { - self.runtime_layout_set_split_ratio( - "tui.layout.set_split_ratio", - crate::api::schema::LayoutSetSplitRatioParams { - tab_id: None, - pane_id: None, - path, - ratio, - }, - ); - } - - pub(crate) fn cycle_pane_via_api(&mut self, reverse: bool) { - let Some((ws_idx, pane_id)) = self.focused_pane_target() else { - return; - }; - let Some(tab) = self.state.workspaces[ws_idx].active_tab() else { - return; - }; - let ids = tab.layout.pane_ids(); - let Some(pos) = ids.iter().position(|id| *id == pane_id) else { - return; - }; - let target = if reverse { - ids[(pos + ids.len() - 1) % ids.len()] - } else { - ids[(pos + 1) % ids.len()] - }; - self.focus_pane_internal_via_api(ws_idx, target); - } - - pub(crate) fn last_pane_via_api(&mut self) { - let Some(target) = self.state.previous_pane_focus.clone() else { - return; - }; - let Some((ws_idx, _tab_idx)) = self.state.pane_focus_target_indices(&target) else { - self.state.previous_pane_focus = None; - return; - }; - if self.state.current_pane_focus_target().as_ref() == Some(&target) { - self.state.previous_pane_focus = None; - return; - } - self.focus_pane_internal_via_api(ws_idx, target.pane_id); - } - - pub(crate) fn focus_toast_target_via_api(&mut self) { - let Some(target) = self - .state - .toast - .as_ref() - .and_then(|toast| toast.target.clone()) - else { - return; - }; - let Some(ws_idx) = self - .state - .workspaces - .iter() - .position(|workspace| workspace.id == target.workspace_id) - else { - return; - }; - self.focus_pane_internal_via_api(ws_idx, target.pane_id); - self.state.toast = None; - self.state.mode = Mode::Terminal; - } - - fn focused_pane_target(&self) -> Option<(usize, crate::layout::PaneId)> { - let ws_idx = self.state.active?; - let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?; - Some((ws_idx, pane_id)) - } - - fn directional_pane_target_from_view( - &self, - direction: NavDirection, - ) -> Option<(usize, crate::layout::PaneId)> { - let ws_idx = self.state.active?; - let focused = self - .state - .view - .pane_infos - .iter() - .find(|pane| pane.is_focused)?; - let target = - crate::layout::find_in_direction(focused, direction, &self.state.view.pane_infos)?; - Some((ws_idx, target)) - } - - fn directional_pane_swap_from_view( - &self, - direction: NavDirection, - ) -> Option<(usize, crate::layout::PaneId, crate::layout::PaneId)> { - let ws_idx = self.state.active?; - let focused = self - .state - .view - .pane_infos - .iter() - .find(|pane| pane.is_focused)?; - let target = - crate::layout::find_in_direction(focused, direction, &self.state.view.pane_infos)?; - Some((ws_idx, focused.id, target)) - } - - fn relative_visible_workspace(&self, delta: isize) -> Option { - let order = self.state.visible_workspace_order(); - if order.is_empty() { - return None; - } - let current = self.state.active.unwrap_or(self.state.selected); - let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); - let next = (current_pos as isize + delta).rem_euclid(order.len() as isize) as usize; - order.get(next).copied() - } - - fn active_tab_move(&self, delta: isize) -> Option<(usize, usize, usize)> { - let ws_idx = self.state.active?; - let ws = self.state.workspaces.get(ws_idx)?; - let source = ws.active_tab; - let insert = tab_move_insert_index(ws.tabs.len(), source, delta)?; - Some((ws_idx, source, insert)) - } - - fn relative_tab(&self, delta: isize) -> Option { - let ws = self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get(ws_idx))?; - if ws.tabs.is_empty() { - return None; - } - Some((ws.active_tab as isize + delta).rem_euclid(ws.tabs.len() as isize) as usize) - } - - fn agent_entry_target(&self, idx: usize) -> Option<(usize, crate::layout::PaneId)> { - let entries = crate::ui::agent_panel_entries(&self.state); - let target = entries.get(idx)?; - Some((target.ws_idx, target.pane_id)) - } - - fn relative_agent_entry(&self, forward: bool) -> Option<(usize, usize, crate::layout::PaneId)> { - let entries = crate::ui::agent_panel_entries(&self.state); - if entries.is_empty() { - return None; - } - let focused = self - .state - .active - .and_then(|idx| self.state.workspaces.get(idx)) - .and_then(crate::workspace::Workspace::focused_pane_id); - let current_idx = entries - .iter() - .position(|entry| Some(entry.pane_id) == focused); - let next_idx = match (current_idx, forward) { - (Some(idx), true) => (idx + 1) % entries.len(), - (Some(0), false) => entries.len() - 1, - (Some(idx), false) => idx - 1, - (None, true) => 0, - (None, false) => entries.len() - 1, - }; - let target = entries.get(next_idx)?; - Some((next_idx, target.ws_idx, target.pane_id)) - } - - fn pass_through_key_to_focused_pane(&mut self, key: TerminalKey) -> bool { - let Some(ws_idx) = self.state.active else { - return false; - }; - let Some(rt) = self - .state - .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) - else { - return false; - }; - - let bytes = rt.encode_terminal_key(key.clone()); - if bytes.is_empty() || rt.try_send_bytes(Bytes::from(bytes)).is_err() { - return false; - } - - self.state.mode = Mode::Terminal; - true - } - - pub(super) fn launch_custom_command( - &mut self, - binding: crate::config::CustomCommandKeybind, - context: ActionContext, - ) { - let previous_mode = self.state.mode; - let previous_toast = self.state.toast.clone(); - let result = self.execute_custom_command_binding(&binding); - match result { - Ok(()) => finish_custom_command_context(&mut self.state, context, previous_mode), - Err(err) => { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "custom command failed".to_string(), - context: err.to_string(), - position: None, - target: None, - }); - self.sync_toast_deadline(previous_toast); - finish_custom_command_context(&mut self.state, context, previous_mode); - } - } - } - - pub(crate) fn execute_custom_command_binding( - &mut self, - binding: &crate::config::CustomCommandKeybind, - ) -> io::Result<()> { - match binding.action { - crate::config::CustomCommandAction::Shell => self.spawn_custom_command(binding), - crate::config::CustomCommandAction::Pane => { - self.spawn_pane_command(&binding.command, Vec::new()) - } - crate::config::CustomCommandAction::Popup => self.spawn_custom_popup_command(binding), - crate::config::CustomCommandAction::PluginAction => self - .invoke_plugin_action_from_keybind(binding.command.clone()) - .map_err(io::Error::other), - } - } - - fn spawn_custom_popup_command( - &mut self, - binding: &crate::config::CustomCommandKeybind, - ) -> io::Result<()> { - self.spawn_popup_shell_command( - &binding.command, - None, - self.custom_command_env().0, - crate::app::popup::PopupGeometry { - width: binding.width, - height: binding.height, - }, - ) - } - - pub(crate) fn custom_command_env(&self) -> (Vec<(String, String)>, Option) { - let mut env = vec![( - crate::api::SOCKET_PATH_ENV_VAR.to_string(), - crate::api::socket_path().display().to_string(), - )]; - if let Ok(current_exe) = std::env::current_exe() { - env.push(( - "HERDR_BIN_PATH".to_string(), - current_exe.display().to_string(), - )); - } - - let mut cwd = None; - if let Some(ws_idx) = self.state.active { - env.push(( - "HERDR_ACTIVE_WORKSPACE_ID".to_string(), - self.public_workspace_id(ws_idx), - )); - if let Some(workspace) = self.state.workspaces.get(ws_idx) { - let tab_idx = workspace.active_tab_index(); - if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { - env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id)); - } - if let Some(pane_id) = workspace.focused_pane_id() { - if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { - env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); - } - if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| { - tab.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) - }) { - env.push(( - "HERDR_ACTIVE_PANE_CWD".to_string(), - pane_cwd.display().to_string(), - )); - if pane_cwd.is_dir() { - cwd = Some(pane_cwd); - } - } - } - } - } - (env, cwd) - } - - fn spawn_custom_command( - &mut self, - binding: &crate::config::CustomCommandKeybind, - ) -> std::io::Result<()> { - let mut command = crate::platform::detached_custom_command_process(&binding.command); - command - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - let (env, cwd) = self.custom_command_env(); - command.envs(env); - if let Some(cwd) = cwd { - command.current_dir(cwd); - } - let child = command.spawn()?; - self.detached_process_children.push(child); - Ok(()) - } - - pub(super) fn launch_focused_scrollback_editor(&mut self) { - let previous_toast = self.state.toast.clone(); - match self.open_focused_scrollback_in_editor() { - Ok(()) => self.sync_toast_deadline(previous_toast), - Err(err) => { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "edit scrollback failed".to_string(), - context: err.to_string(), - position: None, - target: None, - }); - self.sync_toast_deadline(previous_toast); - } - } - } - - pub(crate) fn open_focused_scrollback_in_editor(&mut self) -> std::io::Result<()> { - let ws_idx = self - .state - .active - .ok_or_else(|| std::io::Error::other("no active workspace"))?; - let ws = self - .state - .workspaces - .get(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let pane_id = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let scrollback = self - .state - .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) - .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))? - .recent_unwrapped_text_snapshot(usize::MAX) - .text; - - let path = write_scrollback_temp_file(&scrollback)?; - - let argv = match crate::platform::scrollback_editor_argv(&path) { - Ok(argv) => argv, - Err(err) => { - let _ = fs::remove_file(&path); - return Err(err); - } - }; - let (env, _) = self.custom_command_env(); - let new_pane = match self.spawn_overlay_argv_command(&argv, None, env, vec![path.clone()]) { - Ok((_, new_pane)) => new_pane, - Err(err) => { - let _ = fs::remove_file(&path); - return Err(err); - } - }; - let terminal_id = new_pane.terminal.id.clone(); - self.terminal_runtimes - .insert(terminal_id.clone(), new_pane.runtime); - self.state - .remove_alias_shadowed_by_new_pane(new_pane.pane_id); - self.state.terminals.insert(terminal_id, new_pane.terminal); - - if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "opened scrollback".to_string(), - context: format!("focused pane {public_pane_id}"), - position: None, - target: None, - }); - } - Ok(()) - } - - fn spawn_pane_command( - &mut self, - command: &str, - temp_files: Vec, - ) -> std::io::Result<()> { - let Some(ws_idx) = self.state.active else { - return Err(std::io::Error::other("no active workspace")); - }; - let previous_focus_target = self.state.current_pane_focus_target(); - let (rows, cols) = self.state.estimate_pane_size(); - let new_rows = rows.max(4); - let new_cols = cols.max(10); - let (env, _) = self.custom_command_env(); - - let ws = self - .state - .workspaces - .get_mut(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let tab_idx = ws.active_tab_index(); - let previous_focus = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); - let cwd = ws.active_tab().and_then(|tab| { - tab.cwd_for_pane( - previous_focus, - &self.state.terminals, - &self.terminal_runtimes, - ) - }); - let new_pane = ws.split_focused_command( - Direction::Horizontal, - new_rows, - new_cols, - cwd, - command, - env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.state.host_terminal_appearance, - )?; - let new_pane_id = new_pane.pane_id; - self.terminal_runtimes - .insert(new_pane.terminal.id.clone(), new_pane.runtime); - self.state - .terminals - .insert(new_pane.terminal.id.clone(), new_pane.terminal); - let new_focus_target = crate::app::state::PaneFocusTarget { - workspace_id: ws.id.clone(), - pane_id: new_pane_id, - }; - if previous_focus_target.as_ref() != Some(&new_focus_target) { - self.state.previous_pane_focus = previous_focus_target; - } - ws.active_tab_mut() - .expect("workspace must have an active tab") - .layout - .focus_pane(new_pane_id); - ws.active_tab_mut() - .expect("workspace must have an active tab") - .zoomed = true; - self.overlay_panes.insert( - new_pane_id, - super::super::OverlayPaneState { - ws_idx, - tab_idx, - previous_focus, - previous_zoomed, - temp_files, - }, - ); - self.state.remove_alias_shadowed_by_new_pane(new_pane_id); - self.state.mode = Mode::Terminal; - Ok(()) - } - - pub(crate) fn spawn_overlay_argv_command( - &mut self, - argv: &[String], - cwd: Option, - extra_env: Vec<(String, String)>, - temp_files: Vec, - ) -> std::io::Result<(usize, crate::workspace::NewPane)> { - let Some(ws_idx) = self.state.active else { - return Err(std::io::Error::other("no active workspace")); - }; - let previous_focus_target = self.state.current_pane_focus_target(); - let (rows, cols) = self.state.estimate_pane_size(); - let new_rows = rows.max(4); - let new_cols = cols.max(10); - - let ws = self - .state - .workspaces - .get(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let previous_focus = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let cwd = cwd.or_else(|| { - ws.active_tab().and_then(|tab| { - tab.cwd_for_pane( - previous_focus, - &self.state.terminals, - &self.terminal_runtimes, - ) - }) - }); - - let (tab_idx, new_pane, workspace_id) = { - let ws = self - .state - .workspaces - .get_mut(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); - let result = ws.split_pane_argv_command( - previous_focus, - Direction::Horizontal, - new_rows, - new_cols, - cwd, - argv, - extra_env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.state.host_terminal_appearance, - true, - ); - let (tab_idx, new_pane) = match result { - Some(Ok(result)) => result, - Some(Err(err)) => return Err(err), - None => return Err(std::io::Error::other("focused pane disappeared")), - }; - ws.tabs - .get_mut(tab_idx) - .ok_or_else(|| std::io::Error::other("plugin overlay tab disappeared"))? - .zoomed = true; - self.overlay_panes.insert( - new_pane.pane_id, - super::super::OverlayPaneState { - ws_idx, - tab_idx, - previous_focus, - previous_zoomed, - temp_files, - }, - ); - (tab_idx, new_pane, ws.id.clone()) - }; - - let new_focus_target = crate::app::state::PaneFocusTarget { - workspace_id, - pane_id: new_pane.pane_id, - }; - if previous_focus_target.as_ref() != Some(&new_focus_target) { - self.state.previous_pane_focus = previous_focus_target; - } - self.state.switch_workspace_tab(ws_idx, tab_idx); - self.state.mode = Mode::Terminal; - Ok((ws_idx, new_pane)) - } -} - -fn prefix_binding_for_key(state: &AppState, key: &TerminalKey) -> Option { - crate::input::resolve_prefix_binding(&state.keybinds, key) -} - -pub(crate) fn command_for_key( - state: &AppState, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> Option { - crate::input::resolve_custom_command(&state.keybinds, key, dispatch) -} - -fn unmodified_digit_for_key(key: &TerminalKey) -> Option { - ('1'..='9').find(|digit| { - crate::config::terminal_key_matches_combo( - key, - ( - KeyCode::Char(*digit), - crossterm::event::KeyModifiers::empty(), - ), - ) - }) -} - -#[cfg(test)] -pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKey) -> bool { - if let Some(c) = unmodified_digit_for_key(&key) { - let idx = (c as usize) - ('1' as usize); - if let Some(ws_idx) = state.workspace_at_visible_position(idx) { - state.switch_workspace(ws_idx); - leave_navigate_mode(state); - } - return true; - } - - let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); - if modifiers.is_empty() { - match code { - KeyCode::Enter => { - if !state.workspaces.is_empty() { - state.switch_workspace(state.selected); - leave_navigate_mode(state); - } - return true; - } - KeyCode::Tab => { - state.cycle_pane(false); - return true; - } - KeyCode::BackTab => { - state.cycle_pane(true); - return true; - } - KeyCode::Left => { - state.navigate_pane(NavDirection::Left); - return true; - } - KeyCode::Right => { - state.navigate_pane(NavDirection::Right); - return true; - } - _ => {} - } - } - - if state - .keybinds - .navigate - .workspace_up - .matches_direct_key(&key) - { - state.move_selected_workspace_by_visible_delta(-1); - return true; - } - if state - .keybinds - .navigate - .workspace_down - .matches_direct_key(&key) - { - state.move_selected_workspace_by_visible_delta(1); - return true; - } - if state.keybinds.navigate.pane_left.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Left); - return true; - } - if state.keybinds.navigate.pane_down.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Down); - return true; - } - if state.keybinds.navigate.pane_up.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Up); - return true; - } - if state.keybinds.navigate.pane_right.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Right); - return true; - } - - false -} - -fn navigate_reserved_action_for_key(state: &AppState, key: &TerminalKey) -> Option { - if let Some(c) = unmodified_digit_for_key(key) { - return Some(NavigateAction::SwitchWorkspace( - (c as usize) - ('1' as usize), - )); - } - - let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); - if modifiers.is_empty() { - match code { - KeyCode::Enter => { - return (!state.workspaces.is_empty()).then_some(NavigateAction::SwitchWorkspace( - state - .visible_workspace_order() - .iter() - .position(|idx| *idx == state.selected) - .unwrap_or(state.selected), - )); - } - KeyCode::Tab => return Some(NavigateAction::CyclePaneNext), - KeyCode::BackTab => return Some(NavigateAction::CyclePanePrevious), - KeyCode::Left => return Some(NavigateAction::FocusPaneLeft), - KeyCode::Right => return Some(NavigateAction::FocusPaneRight), - _ => {} - } - } - - if state.keybinds.navigate.workspace_up.matches_direct_key(key) - || state - .keybinds - .navigate - .workspace_down - .matches_direct_key(key) - { - return None; - } - if state.keybinds.navigate.pane_left.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneLeft); - } - if state.keybinds.navigate.pane_down.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneDown); - } - if state.keybinds.navigate.pane_up.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneUp); - } - if state.keybinds.navigate.pane_right.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneRight); - } - - None -} - -pub(super) fn api_pane_direction(direction: NavDirection) -> crate::api::schema::PaneDirection { - match direction { - NavDirection::Left => crate::api::schema::PaneDirection::Left, - NavDirection::Right => crate::api::schema::PaneDirection::Right, - NavDirection::Up => crate::api::schema::PaneDirection::Up, - NavDirection::Down => crate::api::schema::PaneDirection::Down, - } -} - -#[cfg(test)] -pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_dismissed = true; - let terminal_key = TerminalKey::from(key); - - if state.is_prefix_key(&terminal_key) || key.code == KeyCode::Esc { - leave_navigate_mode(state); - return; - } - - if handle_navigate_reserved_key(state, terminal_key.clone()) { - return; - } - - if let Some(action) = navigate_mode_action_for_key(state, terminal_key) { - execute_navigate_action_in_context( - state, - &mut terminal_runtimes, - action, - ActionContext::Navigate, - ); - } -} - -fn copy_mode_survives_prefix_action(action: NavigateAction) -> bool { - matches!( - action, - NavigateAction::SwitchWorkspace(_) - | NavigateAction::SwitchTab(_) - | NavigateAction::FocusAgent(_) - | NavigateAction::PreviousWorkspace - | NavigateAction::NextWorkspace - | NavigateAction::PreviousAgent - | NavigateAction::NextAgent - | NavigateAction::PreviousTab - | NavigateAction::NextTab - | NavigateAction::FocusPaneLeft - | NavigateAction::FocusPaneDown - | NavigateAction::FocusPaneUp - | NavigateAction::FocusPaneRight - | NavigateAction::CyclePaneNext - | NavigateAction::CyclePanePrevious - | NavigateAction::LastPane - | NavigateAction::OpenNotificationTarget - ) -} - -fn indexed_navigation_action( - state: &AppState, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> Option { - crate::input::resolve_indexed_action(&state.keybinds, key, dispatch) -} - -#[cfg(test)] -fn action_for_key( - state: &AppState, - key: TerminalKey, - dispatch: BindingDispatch, -) -> Option { - non_indexed_action_for_key(state, &key, dispatch) - .or_else(|| indexed_navigation_action(state, &key, dispatch)) -} - -fn non_indexed_action_for_key( - state: &AppState, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> Option { - crate::input::resolve_non_indexed_action(&state.keybinds, key, dispatch) -} - -#[cfg(test)] -fn navigate_mode_action_for_key(state: &AppState, key: TerminalKey) -> Option { - let action = action_for_key(state, key, BindingDispatch::Prefix)?; - if matches!( - action, - NavigateAction::FocusPaneLeft - | NavigateAction::FocusPaneDown - | NavigateAction::FocusPaneUp - | NavigateAction::FocusPaneRight - ) { - return None; - } - Some(action) -} - -fn navigate_mode_non_indexed_action_for_key( - state: &AppState, - key: &TerminalKey, -) -> Option { - let action = non_indexed_action_for_key(state, key, BindingDispatch::Prefix)?; - if matches!( - action, - NavigateAction::FocusPaneLeft - | NavigateAction::FocusPaneDown - | NavigateAction::FocusPaneUp - | NavigateAction::FocusPaneRight - ) { - return None; - } - Some(action) -} - -fn navigate_mode_indexed_action_for_key( - state: &AppState, - key: &TerminalKey, -) -> Option { - indexed_navigation_action(state, key, BindingDispatch::Prefix) -} - -#[cfg(test)] -pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - execute_navigate_action_in_context( - state, - &mut terminal_runtimes, - action, - ActionContext::Navigate, - ); -} - -#[cfg(test)] -pub(super) fn execute_navigate_action_in_context( - state: &mut AppState, - terminal_runtimes: &mut TerminalRuntimeRegistry, - action: NavigateAction, - context: ActionContext, -) { - let previous_mode = state.mode; - match action { - NavigateAction::NewWorkspace => { - state.request_new_workspace = true; - leave_navigate_mode(state); - } - NavigateAction::NewWorktree => { - if let Some(ws_idx) = workspace_action_target(state, context) - .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx)) - { - state.request_new_linked_worktree = Some(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::OpenWorktree => { - if let Some(ws_idx) = workspace_action_target(state, context) - .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx)) - { - state.request_open_existing_worktree = Some(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::RemoveWorktree => { - if let Some(ws_idx) = workspace_action_target(state, context) { - state.request_remove_linked_worktree = Some(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::RenameWorkspace => { - if let Some(ws_idx) = workspace_action_target(state, context) { - super::modal::open_rename_workspace(state, terminal_runtimes, ws_idx); - } - } - NavigateAction::CloseWorkspace => { - if let Some(ws_idx) = workspace_action_target(state, context) { - state.selected = ws_idx; - if state.confirm_close { - super::modal::open_confirm_close(state); - } else { - state.close_selected_workspace(); - leave_navigate_mode(state); - } - } - } - NavigateAction::SwitchWorkspace(idx) => { - if let Some(ws_idx) = state.workspace_at_visible_position(idx) { - state.switch_workspace(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::SwitchTab(idx) => { - let tab_exists = state - .active - .and_then(|ws_idx| state.workspaces.get(ws_idx)) - .is_some_and(|ws| idx < ws.tabs.len()); - if tab_exists { - state.switch_tab(idx); - leave_navigate_mode(state); - } - } - NavigateAction::FocusAgent(idx) => { - if state.focus_agent_entry(idx) { - leave_navigate_mode(state); - } - } - NavigateAction::WorkspacePicker => { - state.mobile_switcher_scroll = 0; - state.mode = Mode::Navigate; - } - NavigateAction::PreviousWorkspace => { - state.previous_workspace(); - leave_navigate_mode(state); - } - NavigateAction::NextWorkspace => { - state.next_workspace(); - leave_navigate_mode(state); - } - NavigateAction::PreviousAgent => { - state.previous_agent(); - leave_navigate_mode(state); - } - NavigateAction::NextAgent => { - state.next_agent(); - leave_navigate_mode(state); - } - NavigateAction::NewTab => { - if state.active.is_some() { - if state.prompt_new_tab_name { - super::modal::open_new_tab_dialog(state); - } else { - state.request_new_tab = true; - leave_navigate_mode(state); - } - } - } - NavigateAction::RenameTab => super::modal::open_rename_active_tab(state, false), - NavigateAction::PreviousTab => { - state.previous_tab(); - leave_navigate_mode(state); - } - NavigateAction::NextTab => { - state.next_tab(); - leave_navigate_mode(state); - } - NavigateAction::MoveTabPrevious => { - move_active_tab_relative(state, -1); - leave_navigate_mode(state); - } - NavigateAction::MoveTabNext => { - move_active_tab_relative(state, 1); - leave_navigate_mode(state); - } - NavigateAction::CloseTab => { - if !state.close_tab() { - leave_navigate_mode(state); - } - } - NavigateAction::RenamePane => { - if let Some(pane_id) = state - .active - .and_then(|ws_idx| state.workspaces.get(ws_idx)) - .and_then(|ws| ws.focused_pane_id()) - { - super::modal::open_rename_pane(state, pane_id); - } - } - NavigateAction::FocusPaneLeft => state.navigate_pane(NavDirection::Left), - NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down), - NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up), - NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right), - NavigateAction::SwapPaneLeft => { - state.swap_pane(NavDirection::Left); - leave_navigate_mode(state); - } - NavigateAction::SwapPaneDown => { - state.swap_pane(NavDirection::Down); - leave_navigate_mode(state); - } - NavigateAction::SwapPaneUp => { - state.swap_pane(NavDirection::Up); - leave_navigate_mode(state); - } - NavigateAction::SwapPaneRight => { - state.swap_pane(NavDirection::Right); - leave_navigate_mode(state); - } - NavigateAction::SplitVertical => { - state.split_pane(terminal_runtimes, Direction::Horizontal); - leave_navigate_mode(state); - } - NavigateAction::SplitHorizontal => { - state.split_pane(terminal_runtimes, Direction::Vertical); - leave_navigate_mode(state); - } - NavigateAction::ClosePane => { - if !state.close_pane() { - leave_navigate_mode(state); - } - } - NavigateAction::EditScrollback => {} - NavigateAction::CopyMode => state.enter_copy_mode(terminal_runtimes), - NavigateAction::Zoom => { - state.toggle_zoom(); - leave_navigate_mode(state); - } - NavigateAction::EnterResizeMode => state.mode = Mode::Resize, - NavigateAction::ResizePaneLeft => { - state.resize_pane(NavDirection::Left); - leave_navigate_mode(state); - } - NavigateAction::ResizePaneDown => { - state.resize_pane(NavDirection::Down); - leave_navigate_mode(state); - } - NavigateAction::ResizePaneUp => { - state.resize_pane(NavDirection::Up); - leave_navigate_mode(state); - } - NavigateAction::ResizePaneRight => { - state.resize_pane(NavDirection::Right); - leave_navigate_mode(state); - } - NavigateAction::ToggleSidebar => { - state.sidebar_collapsed = !state.sidebar_collapsed; - leave_navigate_mode(state); - } - NavigateAction::CyclePaneNext => { - state.cycle_pane(false); - leave_navigate_mode(state); - } - NavigateAction::CyclePanePrevious => { - state.cycle_pane(true); - leave_navigate_mode(state); - } - NavigateAction::LastPane => { - state.last_pane(); - leave_navigate_mode(state); - } - NavigateAction::Help => super::modal::open_keybind_help(state), - NavigateAction::Settings => super::settings::open_settings(state), - NavigateAction::ReloadConfig => { - state.request_reload_config = true; - leave_navigate_mode(state); - } - NavigateAction::OpenNotificationTarget => { - state.focus_toast_target(); - if state.mode == Mode::Navigate { - leave_navigate_mode(state); - } - } - NavigateAction::Detach => { - super::modal::request_detach(state); - leave_navigate_mode(state); - } - NavigateAction::OpenNavigator => state.open_navigator_from(terminal_runtimes), - } - - finish_action_context(state, context, previous_mode); -} - -fn workspace_action_target(state: &AppState, context: ActionContext) -> Option { - let idx = match context { - ActionContext::Direct | ActionContext::Prefix => state.active.unwrap_or(state.selected), - ActionContext::Navigate => state.selected, - }; - (idx < state.workspaces.len()).then_some(idx) -} - -fn workspace_can_start_worktree_action( - state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, -) -> bool { - let Some(ws) = state.workspaces.get(ws_idx) else { - return false; - }; - if ws - .worktree_space() - .is_some_and(|space| space.is_linked_worktree) - { - return false; - } - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from(&state.terminals, terminal_runtimes) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - !git_space.is_some_and(|space| space.is_linked_worktree) -} - -// Translate a one-step move into the pre-removal insertion slot that -// Workspace::move_tab expects, wrapping at either end. None when there is -// nothing to move. -fn tab_move_insert_index(len: usize, source: usize, delta: isize) -> Option { - if len <= 1 { - return None; - } - Some(if delta > 0 { - if source + 1 >= len { - 0 - } else { - source + 2 - } - } else if source == 0 { - len - } else { - source - 1 - }) -} - -#[cfg(test)] -fn move_active_tab_relative(state: &mut AppState, delta: isize) { - let Some(ws) = state - .active - .and_then(|ws_idx| state.workspaces.get_mut(ws_idx)) - else { - return; - }; - let source = ws.active_tab; - if let Some(insert) = tab_move_insert_index(ws.tabs.len(), source, delta) { - ws.move_tab(source, insert); - } -} - -fn leave_navigate_mode(state: &mut AppState) { - if state.active.is_some() { - state.mode = Mode::Terminal; - } -} - -fn finish_action_context(state: &mut AppState, context: ActionContext, previous_mode: Mode) { - if matches!(context, ActionContext::Direct | ActionContext::Prefix) - && state.mode == previous_mode - { - leave_command_mode(state); - } -} - -fn finish_custom_command_context( - state: &mut AppState, - context: ActionContext, - previous_mode: Mode, -) { - if context == ActionContext::Navigate { - leave_navigate_mode(state); - } else { - finish_action_context(state, context, previous_mode); - } -} - -fn leave_command_mode(state: &mut AppState) { - if state.copy_mode_pane_is_focused() { - state.mode = Mode::Copy; - } else if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - }; -} - -fn write_scrollback_temp_file(content: &str) -> io::Result { - let mut last_collision = None; - for attempt in 0..16 { - let path = unique_scrollback_path(attempt); - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - - match options.open(&path) { - Ok(mut file) => { - file.write_all(content.as_bytes())?; - return Ok(path); - } - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { - last_collision = Some(err); - } - Err(err) => return Err(err), - } - } - - Err(last_collision.unwrap_or_else(|| { - io::Error::new( - io::ErrorKind::AlreadyExists, - "failed to create unique scrollback temp file", - ) - })) -} - -fn unique_scrollback_path(attempt: u32) -> std::path::PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0); - std::env::temp_dir().join(format!( - "herdr-scrollback-{}-{nanos}-{attempt}.txt", - std::process::id() - )) -} - -#[cfg(test)] -mod tests { - #[cfg(unix)] - use std::time::Duration; - - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, ModifierKeyCode}; - use ratatui::layout::Direction; - - use super::super::{state_with_workspaces, unique_temp_path}; - #[cfg(unix)] - use super::super::{wait_for_detached_process_reap, wait_for_file}; - use super::*; - use crate::{ - app::App, - config::Config, - input::TerminalKey, - raw_input::{parse_raw_input_bytes_sync, RawInputEvent}, - terminal::TerminalState, - workspace::Workspace, - }; - - fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) { - state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/worktree-{ws_idx}").into(), - is_linked_worktree: ws_idx != 0, - }); - } - - fn app_with_test_workspaces(names: &[&str]) -> App { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); - app.state.ensure_test_terminals(); - app.state.active = (!app.state.workspaces.is_empty()).then_some(0); - app.state.selected = 0; - app - } - - #[test] - fn next_agent_starts_at_first_visible_entry_when_focused_agent_is_filtered_out() { - let mut app = app_with_test_workspaces(&["hidden", "first", "second"]); - for ws_idx in 0..app.state.workspaces.len() { - let pane_id = app.state.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(crate::detect::Agent::Claude); - terminal.state = if ws_idx == 0 { - crate::detect::AgentState::Idle - } else { - crate::detect::AgentState::Working - }; - } - app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams { - source: "example.views".to_string(), - label: None, - filter: Some(crate::api::schema::AgentViewFilter::Eq { - field: crate::api::schema::AgentViewField::Builtin( - crate::api::schema::AgentViewBuiltinField::Status, - ), - value: crate::api::schema::AgentViewValue::String("working".to_string()), - }), - sort: Vec::new(), - }); - - app.execute_tui_navigate_action(NavigateAction::NextAgent, ActionContext::Prefix); - - assert_eq!(app.state.active, Some(1)); - } - - #[test] - fn default_goto_key_opens_navigator() { - let mut state = state_with_workspaces(&["test"]); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - } - - #[test] - fn custom_rename_key_enters_rename_mode() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.name_input, "test"); - } - - #[test] - fn rename_workspace_prefills_live_terminal_cwd_label() { - let mut state = state_with_workspaces(&["stale"]); - let root = state.workspaces[0].tabs[0].root_pane; - let terminal_id = state.workspaces[0].panes[&root] - .attached_terminal_id - .clone(); - state.workspaces[0].custom_name = None; - state.workspaces[0].identity_cwd = "/__herdr_original__".into(); - state.terminals.insert( - terminal_id.clone(), - TerminalState::new(terminal_id, "/__herdr_projects__".into()), - ); - state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.name_input, "__herdr_projects__"); - assert_eq!(state.workspaces[0].display_name(), "__herdr_original__"); - } - - #[test] - fn prefix_rename_workspace_targets_active_workspace_not_stale_selection() { - let mut state = state_with_workspaces(&["main", "issue"]); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.active = Some(1); - state.selected = 0; - state.mode = Mode::Prefix; - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::RenameWorkspace, - ActionContext::Prefix, - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.selected, 1); - assert_eq!(state.name_input, "issue"); - } - - #[test] - fn prefix_close_workspace_targets_active_linked_worktree_without_removing_checkout() { - let mut state = state_with_workspaces(&["main", "issue"]); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.active = Some(1); - state.selected = 0; - state.mode = Mode::Prefix; - state.confirm_close = false; - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::CloseWorkspace, - ActionContext::Prefix, - ); - - assert_eq!(state.request_remove_linked_worktree, None); - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].display_name(), "main"); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_new_workspace_key_requests_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.request_new_workspace); - assert_eq!(state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn new_workspace_key_opens_prefilled_prompt_and_preserves_captured_cwd() { - let cwd = unique_temp_path("workspace-name-suggestion"); - std::fs::create_dir_all(&cwd).unwrap(); - let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); - let mut app = app_with_test_workspaces(&["test"]); - app.state.new_terminal_cwd = - crate::config::NewTerminalCwdConfig::Path(cwd.display().to_string()); - app.state.prompt_new_workspace_name = true; - app.state.mode = Mode::Navigate; - app.state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g"); - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert_eq!(app.state.name_input, suggested_name); - assert!(app.state.name_input_replace_on_type); - assert_eq!(app.state.pending_workspace_create_cwd.as_ref(), Some(&cwd)); - assert_eq!(app.state.workspaces.len(), 1); - - app.state.new_terminal_cwd = - crate::config::NewTerminalCwdConfig::Path("/tmp/changed-after-prompt".into()); - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.workspaces[1].identity_cwd, cwd); - assert!(app.state.workspaces[1].custom_name.is_none()); - assert!(app.state.pending_workspace_create_cwd.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - crate::app::api::test_support::shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(&cwd); - } - - #[tokio::test] - async fn new_workspace_prompt_saves_custom_name_atomically() { - let cwd = unique_temp_path("workspace-custom-name"); - std::fs::create_dir_all(&cwd).unwrap(); - let mut app = app_with_test_workspaces(&["test"]); - app.state.new_terminal_cwd = - crate::config::NewTerminalCwdConfig::Path(cwd.display().to_string()); - app.state.prompt_new_workspace_name = true; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::NewWorkspace, ActionContext::Navigate); - app.state.name_input = " logs ".into(); - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.workspaces[1].custom_name.as_deref(), Some("logs")); - assert_eq!(app.state.workspaces[1].identity_cwd, cwd); - crate::app::api::test_support::shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(&cwd); - } - - #[test] - fn cancelling_new_workspace_prompt_creates_nothing() { - let mut app = app_with_test_workspaces(&["test"]); - app.state.prompt_new_workspace_name = true; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::NewWorkspace, ActionContext::Navigate); - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 1); - assert!(app.state.pending_workspace_create_cwd.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn custom_new_worktree_key_requests_selected_workspace() { - let mut state = state_with_workspaces(&["main", "scratch"]); - state.workspaces[1].identity_cwd = unique_temp_path("navigate-new-worktree-selected"); - state.mode = Mode::Navigate; - state.selected = 1; - state.active = Some(0); - state.keybinds.new_worktree = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.request_new_linked_worktree, Some(1)); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn worktree_actions_do_not_start_from_linked_child_workspace() { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - let mut state = state_with_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 1, "repo-key"); - state.mode = Mode::Navigate; - state.selected = 1; - state.active = Some(0); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::NewWorktree, - ActionContext::Navigate, - ); - assert_eq!(state.request_new_linked_worktree, None); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::OpenWorktree, - ActionContext::Navigate, - ); - assert_eq!(state.request_open_existing_worktree, None); - } - - #[test] - fn direct_new_worktree_action_targets_active_workspace() { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - let mut state = state_with_workspaces(&["main", "scratch"]); - state.workspaces[0].identity_cwd = unique_temp_path("navigate-new-worktree-active"); - state.mode = Mode::Terminal; - state.selected = 1; - state.active = Some(0); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::NewWorktree, - ActionContext::Direct, - ); - - assert_eq!(state.request_new_linked_worktree, Some(0)); - } - - #[test] - fn navigate_down_follows_grouped_sidebar_visual_order() { - let mut state = state_with_workspaces(&["main", "normal", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 2, "repo-key"); - state.mode = Mode::Navigate; - state.active = Some(0); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 2); - } - - #[test] - fn navigate_number_keys_follow_grouped_sidebar_visual_order() { - let mut state = state_with_workspaces(&["main", "normal", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 2, "repo-key"); - state.mode = Mode::Navigate; - state.active = Some(0); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('2'), KeyModifiers::empty()), - ); - - assert_eq!(state.active, Some(2)); - assert_eq!(state.selected, 2); - } - - #[test] - fn indexed_switch_workspace_keybind_follows_grouped_sidebar_visual_order() { - let mut state = state_with_workspaces(&["main", "normal", "issue"]); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 2, "repo-key"); - state.mode = Mode::Prefix; - state.active = Some(0); - state.selected = 0; - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::SwitchWorkspace(1), - ActionContext::Prefix, - ); - - assert_eq!(state.active, Some(2)); - assert_eq!(state.selected, 2); - } - - #[test] - fn custom_sidebar_toggle_key_toggles_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("g"); - assert!(!state.sidebar_collapsed); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.sidebar_collapsed); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_resize_key_enters_resize_mode() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Resize); - } - - #[test] - fn custom_reload_config_key_requests_reload_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.reload_config = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.request_reload_config); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_open_notification_key_focuses_current_toast_target() { - let mut state = state_with_workspaces(&["one", "two"]); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Navigate; - state.keybinds.open_notification_target = crate::config::ActionKeybinds::prefix("g"); - let target_workspace_id = state.workspaces[1].id.clone(); - let target_pane = state.workspaces[1].tabs[0].root_pane; - state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "pi needs attention".into(), - context: "two".into(), - position: None, - target: Some(crate::app::state::ToastTarget { - workspace_id: target_workspace_id, - pane_id: target_pane, - }), - }); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.selected, 1); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(target_pane)); - assert!(state.toast.is_none()); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn movement_action_stays_in_navigate_mode() { - let mut state = state_with_workspaces(&["a", "b"]); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn navigate_workspace_keys_are_configurable() { - let mut state = state_with_workspaces(&["a", "b"]); - let config: Config = toml::from_str( - r#" -[keys] -navigate_workspace_down = "j" -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn navigate_pane_keys_are_configurable() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let below = state.workspaces[0].test_split(Direction::Vertical); - state.workspaces[0].layout.focus_pane(root); - state.view.pane_infos = state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -navigate_workspace_down = "j" -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), - ); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn focus_pane_prefix_rhs_does_not_create_navigate_mode_pane_shortcut() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let below = state.workspaces[0].test_split(Direction::Vertical); - state.workspaces[0].layout.focus_pane(root); - state.view.pane_infos = state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -focus_pane_down = "prefix+f" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn customized_navigate_pane_key_disables_matching_prefix_rhs_fallback() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let below = state.workspaces[0].test_split(Direction::Vertical); - state.workspaces[0].layout.focus_pane(root); - state.view.pane_infos = state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn left_and_right_arrows_remain_permanent_navigate_pane_aliases() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - state.workspaces[0].layout.focus_pane(right); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -navigate_pane_left = "ctrl+h" -navigate_pane_right = "ctrl+l" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Left, KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 24)); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Right, KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn mobile_workspace_keyboard_navigation_keeps_selected_row_visible() { - let mut state = state_with_workspaces(&["a", "b", "c", "d"]); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Navigate; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 44, 8)); - assert_eq!(state.mobile_switcher_scroll, 0); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mobile_switcher_scroll, 1); - } - - #[test] - fn terminal_direct_agent_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.next_agent = crate::config::ActionKeybinds::direct("alt+a"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('a'), KeyModifiers::ALT), - ); - - assert_eq!(action, Some(NavigateAction::NextAgent)); - } - - #[test] - fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+left"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Left, KeyModifiers::ALT), - ); - - assert_eq!(action, Some(NavigateAction::FocusPaneLeft)); - } - - #[test] - fn terminal_direct_swap_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.swap_pane_right = crate::config::ActionKeybinds::direct("alt+shift+l"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT | KeyModifiers::SHIFT), - ); - - assert_eq!(action, Some(NavigateAction::SwapPaneRight)); - } - - #[test] - fn terminal_direct_resize_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.resize_pane_right = - crate::config::ActionKeybinds::direct("ctrl+shift+alt+right"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new( - KeyCode::Right, - KeyModifiers::CONTROL | KeyModifiers::SHIFT | KeyModifiers::ALT, - ), - ); - - assert_eq!(action, Some(NavigateAction::ResizePaneRight)); - } - - #[test] - fn prefix_resize_pane_binding_maps_to_navigation_action() { - let config: Config = toml::from_str( - r#" -[keys] -resize_pane_left = "prefix+shift+left" -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = config.keybinds(); - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Left, KeyModifiers::SHIFT), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::ResizePaneLeft)); - } - - #[test] - fn terminal_direct_move_tab_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.move_tab_next = crate::config::ActionKeybinds::direct("alt+shift+right"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Right, KeyModifiers::ALT | KeyModifiers::SHIFT), - ); - - assert_eq!(action, Some(NavigateAction::MoveTabNext)); - } - - fn tab_labels(state: &AppState) -> Vec { - let ws = &state.workspaces[0]; - (0..ws.tabs.len()) - .map(|tab_idx| ws.tab_display_name(tab_idx).unwrap()) - .collect() - } - - #[test] - fn move_tab_actions_reorder_and_wrap_the_active_tab() { - let mut state = state_with_workspaces(&["test"]); - { - let ws = &mut state.workspaces[0]; - ws.tabs[0].set_custom_name("a".into()); - ws.test_add_tab(Some("b")); - ws.test_add_tab(Some("c")); - ws.switch_tab(1); - } - - execute_navigate_action(&mut state, NavigateAction::MoveTabNext); - assert_eq!(tab_labels(&state), vec!["a", "c", "b"]); - assert_eq!(state.workspaces[0].active_tab, 2); - - execute_navigate_action(&mut state, NavigateAction::MoveTabNext); - assert_eq!(tab_labels(&state), vec!["b", "a", "c"]); - assert_eq!(state.workspaces[0].active_tab, 0); - - execute_navigate_action(&mut state, NavigateAction::MoveTabPrevious); - assert_eq!(tab_labels(&state), vec!["a", "c", "b"]); - assert_eq!(state.workspaces[0].active_tab, 2); - state.workspaces[0].assert_invariants_for_test(); - } - - #[test] - fn move_tab_is_a_noop_with_a_single_tab() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].tabs[0].set_custom_name("only".into()); - - execute_navigate_action(&mut state, NavigateAction::MoveTabNext); - - assert_eq!(tab_labels(&state), vec!["only"]); - assert_eq!(state.workspaces[0].active_tab, 0); - } - - #[test] - fn move_tab_with_a_single_tab_still_exits_navigate_mode() { - let event_hub = crate::api::EventHub::default(); - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = crate::app::App::new( - &crate::config::Config::default(), - true, - None, - api_rx, - event_hub, - ); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("solo")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::MoveTabNext, ActionContext::Navigate); - - assert_eq!(app.state.workspaces[0].tabs.len(), 1); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn terminal_direct_last_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.last_pane = crate::config::ActionKeybinds::direct("alt+l"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT), - ); - - assert_eq!(action, Some(NavigateAction::LastPane)); - } - - #[test] - fn generated_character_prefix_binding_falls_back_after_exact_chord() { - let generated_key = crate::input::parse_terminal_key_sequence("\x1b[119;3;124u").unwrap(); - assert_eq!(generated_key.code, KeyCode::Char('w')); - assert_eq!(generated_key.modifiers, KeyModifiers::ALT); - assert_eq!(generated_key.generated_text.as_deref(), Some("|")); - - let generated_only: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = generated_only.keybinds(); - assert!(matches!( - prefix_binding_for_key(&state, &generated_key), - Some(PrefixBindingMatch::Action(NavigateAction::SplitVertical)) - )); - let multi_character_key = - crate::input::parse_terminal_key_sequence("\x1b[119;3;124:120u").unwrap(); - assert!(prefix_binding_for_key(&state, &multi_character_key).is_none()); - - let exact_and_generated: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" -split_horizontal = "prefix+alt+w" -"#, - ) - .unwrap(); - state.keybinds = exact_and_generated.keybinds(); - assert!(matches!( - prefix_binding_for_key(&state, &generated_key), - Some(PrefixBindingMatch::Action(NavigateAction::SplitHorizontal)) - )); - - let exact_command: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" - -[[keys.command]] -key = "prefix+alt+w" -command = "echo exact" -"#, - ) - .unwrap(); - state.keybinds = exact_command.keybinds(); - assert!(matches!( - prefix_binding_for_key(&state, &generated_key), - Some(PrefixBindingMatch::Command(binding)) if binding.command == "echo exact" - )); - } - - #[test] - fn shifted_backslash_layout_prefers_horizontal_split_binding() { - let config: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" -split_horizontal = 'prefix+\' -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = config.keybinds(); - let key = crate::input::parse_terminal_key_sequence("\x1b[124:92;2:1u").unwrap(); - assert_eq!(key.code, KeyCode::Char('|')); - assert_eq!(key.modifiers, KeyModifiers::SHIFT); - assert_eq!(key.shifted_codepoint, Some('\\' as u32)); - assert!(state.keybinds.split_horizontal.matches_prefix_key(&key)); - assert!(!state.keybinds.split_vertical.matches_prefix_key(&key)); - - assert_eq!( - action_for_key(&state, key, BindingDispatch::Prefix), - Some(NavigateAction::SplitHorizontal) - ); - assert_eq!( - action_for_key( - &state, - TerminalKey::new(KeyCode::Char('|'), KeyModifiers::empty()), - BindingDispatch::Prefix, - ), - Some(NavigateAction::SplitVertical) - ); - } - - #[test] - fn prefix_tab_override_can_map_to_last_pane() { - let config: Config = toml::from_str( - r#" -[keys] -last_pane = "prefix+tab" -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = config.keybinds(); - - let pane_action = action_for_key( - &state, - TerminalKey::new(KeyCode::Tab, KeyModifiers::empty()), - BindingDispatch::Prefix, - ); - - assert_eq!(pane_action, Some(NavigateAction::LastPane)); - } - - #[test] - fn terminal_direct_indexed_tab_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - let config: Config = toml::from_str("[keys]\nswitch_tab = \"ctrl+3\"\n").unwrap(); - state.keybinds.switch_tab = config.keybinds().switch_tab; - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('3'), KeyModifiers::CONTROL), - ); - - assert_eq!(action, Some(NavigateAction::SwitchTab(2))); - } - - #[test] - fn prefix_shift_indexed_workspace_shortcut_maps_legacy_us_symbol_key() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = - toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); - state.keybinds.switch_workspace = config.keybinds().switch_workspace; - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Char('@'), KeyModifiers::empty()), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::SwitchWorkspace(1))); - } - - #[test] - fn prefix_shift_indexed_workspace_shortcut_maps_non_us_number_rows() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = - toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); - state.keybinds.switch_workspace = config.keybinds().switch_workspace; - - for key in [ - TerminalKey::new(KeyCode::Char('2'), KeyModifiers::SHIFT) - .with_shifted_codepoint('"' as u32), - TerminalKey::new(KeyCode::Char('é'), KeyModifiers::SHIFT) - .with_shifted_codepoint('2' as u32), - ] { - assert_eq!( - action_for_key(&state, key, BindingDispatch::Prefix), - Some(NavigateAction::SwitchWorkspace(1)) - ); - } - } - - #[test] - fn prefix_shift_indexed_workspace_shortcut_survives_modifier_press() { - let mut app = app_with_test_workspaces(&["one", "two"]); - let config: Config = - toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); - app.state.keybinds.switch_workspace = config.keybinds().switch_workspace; - app.state.mode = Mode::Prefix; - - app.handle_prefix_key(TerminalKey::new( - KeyCode::Modifier(ModifierKeyCode::LeftShift), - KeyModifiers::SHIFT, - )); - - assert_eq!(app.state.mode, Mode::Prefix); - - app.handle_prefix_key( - TerminalKey::new(KeyCode::Char('2'), KeyModifiers::SHIFT) - .with_shifted_codepoint('"' as u32), - ); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn prefix_unshifted_indexed_shortcut_maps_shifted_french_number_row() { - let mut state = state_with_workspaces(&["one"]); - let config: Config = toml::from_str("[keys]\nswitch_tab = \"prefix+1..9\"\n").unwrap(); - state.keybinds.switch_tab = config.keybinds().switch_tab; - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Char('é'), KeyModifiers::SHIFT) - .with_shifted_codepoint('2' as u32), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::SwitchTab(1))); - } - - #[test] - fn literal_symbol_binding_takes_precedence_over_shifted_indexed_alias() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = toml::from_str( - r#" -[keys] -help = "prefix+!" -switch_workspace = "prefix+shift+1..9" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty()), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::Help)); - } - - #[test] - fn literal_symbol_custom_command_is_visible_before_shifted_indexed_alias() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = toml::from_str( - r#" -[keys] -switch_workspace = "prefix+shift+1..9" - -[[keys.command]] -key = "prefix+!" -command = "echo literal" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - let key = TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty()); - assert!(command_for_key(&state, &key, BindingDispatch::Prefix).is_some()); - assert_eq!( - indexed_navigation_action(&state, &key, BindingDispatch::Prefix), - Some(NavigateAction::SwitchWorkspace(0)) - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn literal_symbol_custom_command_runs_before_shifted_indexed_alias() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.state.active = Some(1); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("literal-symbol-custom-command"); - let config: Config = toml::from_str(&format!( - r#" -[keys] -switch_workspace = "prefix+shift+1..9" - -[[keys.command]] -key = "prefix+!" -command = "printf literal > '{}'" -"#, - output_path.display() - )) - .unwrap(); - app.state.keybinds = config.keybinds(); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty())) - .await; - - assert_eq!(wait_for_file(&output_path), "literal"); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - let _ = std::fs::remove_file(output_path); - } - - #[tokio::test] - async fn navigate_mode_runs_prefix_action_rhs_without_pressing_prefix_again() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::SHIFT)); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn navigate_mode_matches_legacy_uppercase_shifted_letter() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn legacy_uppercase_prefers_shifted_workspace_binding_over_unshifted() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('W'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - } - - #[tokio::test] - async fn kitty_shifted_alternate_without_modifier_prefers_reload_over_resize() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Prefix; - - let mut events = parse_raw_input_bytes_sync(b"\x1b[114:82;1u"); - assert_eq!(events.len(), 1); - let RawInputEvent::Key(key) = events.remove(0) else { - panic!("expected key event"); - }; - assert_eq!( - action_for_key(&app.state, key.clone(), BindingDispatch::Prefix), - Some(NavigateAction::ReloadConfig) - ); - app.handle_prefix_key(key); - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn legacy_uppercase_prefers_shifted_reload_binding_over_unshifted() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('R'), KeyModifiers::empty())); - - assert!(!app.state.request_reload_config); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn legacy_uppercase_prefers_shifted_pane_binding_over_unshifted() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('P'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::RenamePane); - } - - #[test] - fn app_navigate_mode_workspace_down_moves_selection() { - let mut app = app_with_test_workspaces(&["one", "two"]); - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Down, KeyModifiers::empty())); - - assert_eq!(app.state.selected, 1); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn app_navigate_mode_maps_french_number_row_to_workspace() { - let mut app = app_with_test_workspaces(&["one", "two"]); - app.state.mode = Mode::Navigate; - - app.handle_navigate_key( - TerminalKey::new(KeyCode::Char('é'), KeyModifiers::SHIFT) - .with_shifted_codepoint('2' as u32), - ); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn app_navigate_mode_workspace_keys_are_configurable() { - let mut app = app_with_test_workspaces(&["one", "two"]); - let config: Config = toml::from_str( - r#" -[keys] -navigate_workspace_down = "j" -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - app.state.keybinds = config.keybinds(); - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())); - - assert_eq!(app.state.selected, 1); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[tokio::test] - async fn prefix_focus_pane_is_one_shot_and_returns_to_terminal() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let root = app.state.workspaces[0].tabs[0].root_pane; - let right = app.state.workspaces[0].test_split(Direction::Horizontal); - app.state.workspaces[0].layout.focus_pane(right); - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn navigate_focus_pane_keeps_navigate_mode_active() { - let mut app = app_with_test_workspaces(&["test"]); - let root = app.state.workspaces[0].tabs[0].root_pane; - let below = app.state.workspaces[0].test_split(Direction::Vertical); - app.state.workspaces[0].layout.focus_pane(below); - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - app.state.mode = Mode::Navigate; - - app.handle_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[tokio::test] - async fn no_op_prefix_action_exits_prefix_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('o'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn unmatched_prefix_rhs_exits_prefix_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::F(12), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn prefix_help_matches_enhanced_shifted_question_mark() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key( - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ) - .await; - - assert_eq!(app.state.mode, Mode::KeybindHelp); - } - - #[test] - fn navigate_mode_help_is_binding_driven() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.help = crate::config::ActionKeybinds::prefix("f"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), - ); - assert_eq!(state.mode, Mode::Navigate); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::KeybindHelp); - } - - #[test] - fn modified_navigate_local_key_can_be_bound_as_prefix_rhs() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("shift+u"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT), - ); - - assert!(state.sidebar_collapsed); - } - - #[test] - fn empty_state_new_tab_is_no_op() { - let mut state = crate::app::state::AppState::test_new(); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.mode = Mode::Prefix; - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::NewTab, - ActionContext::Prefix, - ); - - assert_eq!(state.mode, Mode::Navigate); - assert!(!state.creating_new_tab); - assert!(!state.request_new_tab); - assert!(state.workspaces.is_empty()); - } - - #[test] - fn closing_linked_worktree_closes_workspace_without_removing_checkout() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.selected = 1; - state.active = Some(1); - state.mode = Mode::Navigate; - state.confirm_close = false; - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - execute_navigate_action(&mut state, NavigateAction::CloseWorkspace); - - assert_eq!(state.request_remove_linked_worktree, None); - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].display_name(), "main"); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn tui_close_parent_group_closes_immediately_when_confirmation_disabled() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - app.state.confirm_close = false; - - app.execute_tui_navigate_action(NavigateAction::CloseWorkspace, ActionContext::Navigate); - - assert!(app.state.workspaces.is_empty()); - assert_eq!(app.state.mode, Mode::Navigate); - assert_eq!(app.event_hub.events_after(0).len(), 2); - } - - #[test] - fn prefix_close_pane_last_parent_group_pane_opens_confirmation() { - let mut state = state_with_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 1, "repo-key"); - state.selected = 1; - state.active = Some(0); - state.mode = Mode::Navigate; - - execute_navigate_action(&mut state, NavigateAction::ClosePane); - - assert_eq!(state.selected, 0); - assert_eq!(state.mode, Mode::ConfirmClose); - assert_eq!(state.workspaces.len(), 2); - } - - #[test] - fn tui_close_tab_last_parent_group_workspace_opens_confirmation_via_api() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::CloseTab, ActionContext::Navigate); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - } - - #[test] - fn tui_close_pane_last_parent_group_pane_opens_confirmation_via_api() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::ClosePane, ActionContext::Navigate); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - } - - #[cfg(unix)] - #[tokio::test] - async fn custom_command_runs_from_prefix_key_in_navigate_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("custom-command-keybind"); - let release_path = unique_temp_path("custom-command-release"); - let command = format!( - "printf '%s\\n%s\\n%s\\n%s\\n' \"$$\" \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'; i=0; while [ ! -e '{}' ] && [ \"$i\" -lt 250 ]; do sleep 0.02; i=$((i + 1)); done", - output_path.display(), - release_path.display(), - ); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("m"), - label: "prefix+m".into(), - command, - action: crate::config::CustomCommandAction::Shell, - description: None, - width: None, - height: None, - }]; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - assert_eq!(app.state.mode, Mode::Prefix); - - let launch_started = std::time::Instant::now(); - app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty())) - .await; - assert!(launch_started.elapsed() < Duration::from_secs(2)); - - let content = wait_for_file(&output_path); - let lines: Vec<&str> = content.lines().collect(); - assert_eq!(lines.len(), 4); - let pid = lines[0] - .parse::() - .expect("command should report its pid"); - assert!(crate::platform::process_exists(pid)); - assert_eq!(lines[1], app.state.workspaces[0].id); - assert_eq!(lines[2], format!("{}:t1", app.state.workspaces[0].id)); - assert_eq!(lines[3], format!("{}:p1", app.state.workspaces[0].id)); - assert_eq!(app.state.mode, Mode::Terminal); - - std::fs::write(&release_path, b"release").expect("release command"); - let reaped_by_runtime = wait_for_detached_process_reap(&mut app, pid).await; - if !reaped_by_runtime { - if let Some(child) = app - .detached_process_children - .iter_mut() - .find(|child| child.id() == pid) - { - let _ = child.kill(); - let _ = child.wait(); - } - } - assert!( - reaped_by_runtime, - "detached command child {pid} was not reaped" - ); - - let _ = std::fs::remove_file(output_path); - let _ = std::fs::remove_file(release_path); - } - - #[cfg(unix)] - #[tokio::test] - async fn pane_overlay_command_opens_and_closes_after_exit() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - let (workspace, terminal, runtime) = Workspace::new( - std::env::current_dir().unwrap_or_else(|_| "/".into()), - 24, - 80, - app.state.pane_scrollback_limit_bytes, - app.state.host_terminal_theme, - app.state.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), - app.event_tx.clone(), - app.render_notify.clone(), - app.render_dirty.clone(), - ) - .expect("workspace should spawn"); - let root_pane = workspace.tabs[0].root_pane; - app.state.workspaces = vec![workspace]; - app.terminal_runtimes.insert(terminal.id.clone(), runtime); - app.state.terminals.insert(terminal.id.clone(), terminal); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("custom-pane-command"); - let command = format!("printf done > '{}'", output_path.display()); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("m"), - label: "prefix+m".into(), - command, - action: crate::config::CustomCommandAction::Pane, - description: None, - width: None, - height: None, - }]; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert_eq!(app.terminal_runtimes.len(), 2); - assert!(app.state.workspaces[0].tabs[0].zoomed); - let overlay_pane = app.state.workspaces[0].focused_pane_id().unwrap(); - assert_ne!(overlay_pane, root_pane); - - app.state.last_pane(); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root_pane)); - - app.state.last_pane(); - - assert_eq!( - app.state.workspaces[0].focused_pane_id(), - Some(overlay_pane) - ); - - let _ = wait_for_file(&output_path); - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - if app.drain_internal_events() - && app.state.workspaces[0].tabs[0].layout.pane_count() == 1 - { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert!(!app.state.workspaces[0].tabs[0].zoomed); - assert_eq!(app.state.mode, Mode::Terminal); - let _ = std::fs::remove_file(output_path); - - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } - } - - #[cfg(unix)] - #[tokio::test] - async fn edit_scrollback_key_preserves_logical_lines_in_editor_pane() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - let mut workspace = Workspace::test_new("test"); - let root_pane = workspace.tabs[0].root_pane; - workspace.tabs[0].runtimes.insert( - root_pane, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - 5, - 5, - 4096, - b"ABCDEFGHIJ\r\nKLMNO", - ), - ); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("edit-scrollback"); - let previous_editor = std::env::var_os("EDITOR"); - std::env::set_var( - "EDITOR", - format!("sh -c 'cp \"$1\" {}' sh", output_path.display()), - ); - app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::prefix("g"); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) - .await; - - match previous_editor { - Some(value) => std::env::set_var("EDITOR", value), - None => std::env::remove_var("EDITOR"), - } - - let content = wait_for_file(&output_path); - assert_eq!(content, "ABCDEFGHIJ\nKLMNO"); - assert_eq!(app.state.mode, Mode::Terminal); - assert!( - app.state.terminals.values().any(|terminal| terminal - .launch_argv - .as_ref() - .is_some_and(|argv| argv.first().is_some_and(|program| program == "/bin/sh"))), - "scrollback editor should launch through argv overlay path" - ); - - let _ = std::fs::remove_file(output_path); - } - - #[test] - fn zoom_action_exits_navigate_mode() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_split(Direction::Horizontal); - state.keybinds.zoom = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.workspaces[0].zoomed); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn focus_pane_action_keeps_zoomed_when_changing_focus() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - state.workspaces[0].layout.focus_pane(root); - state.workspaces[0].zoomed = true; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); - - execute_navigate_action(&mut state, NavigateAction::FocusPaneRight); - - assert!(state.workspaces[0].zoomed); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); - } - - #[test] - fn question_mark_opens_keybind_help_from_navigate() { - let mut state = state_with_workspaces(&["test"]); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), - ); - - assert_eq!(state.mode, Mode::KeybindHelp); - } - - #[test] - fn new_tab_action_opens_dialog_without_creating_tab() { - let mut state = state_with_workspaces(&["test"]); - - execute_navigate_action(&mut state, NavigateAction::NewTab); - - assert_eq!(state.mode, Mode::RenameTab); - assert!(state.creating_new_tab); - assert_eq!(state.name_input, "2"); - assert!(state.name_input_replace_on_type); - assert!(!state.request_new_tab); - assert_eq!(state.workspaces[0].tabs.len(), 1); - } - - #[test] - fn new_tab_action_can_skip_rename_dialog() { - let mut state = state_with_workspaces(&["test"]); - state.prompt_new_tab_name = false; - - execute_navigate_action(&mut state, NavigateAction::NewTab); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(state.request_new_tab); - assert!(state.requested_new_tab_name.is_none()); - } - - #[test] - fn navigate_q_detaches() { - let mut state = crate::app::state::AppState::test_new(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()), - ); - - assert!(state.detach_requested); - assert!(!state.should_quit); - } -} diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs deleted file mode 100644 index 1e7675d6..00000000 --- a/src/app/input/overlays.rs +++ /dev/null @@ -1,790 +0,0 @@ -use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; -use ratatui::{ - layout::Rect, - widgets::{Block, Borders}, -}; - -use crate::app::{ - state::{AppState, DragState, DragTarget, Mode, NavigatorTarget}, - App, -}; - -use super::{ - modal::{keybind_help_back, leave_modal, modal_action_from_buttons, ModalAction}, - ScrollbarClickTarget, -}; - -fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { - col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height -} - -impl App { - pub(super) fn handle_overlay_mouse(&mut self, mouse: MouseEvent) -> bool { - if self.state.mode == Mode::ReleaseNotes { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) - if self - .state - .release_notes_close_button_at(mouse.column, mouse.row) => - { - self.dismiss_release_notes(); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = self - .state - .release_notes_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.state.drag = Some(DragState { - target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.state - .set_release_notes_offset_from_bottom(offset_from_bottom); - } - } - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(DragState { - target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, - }) = &self.state.drag - { - if let Some(offset_from_bottom) = self - .state - .release_notes_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.state - .set_release_notes_offset_from_bottom(offset_from_bottom); - } - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.state.drag = None; - } - MouseEventKind::ScrollUp => self.scroll_release_notes(-3), - MouseEventKind::ScrollDown => self.scroll_release_notes(3), - _ => {} - } - return true; - } - - if self.state.mode == Mode::ProductAnnouncement { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) - if self - .state - .product_announcement_close_button_at(mouse.column, mouse.row) => - { - self.dismiss_product_announcement(); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = self - .state - .product_announcement_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.state.drag = Some(DragState { - target: DragTarget::ProductAnnouncementScrollbar { - grab_row_offset, - }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => self - .state - .set_product_announcement_offset_from_bottom(offset_from_bottom), - } - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(DragState { - target: DragTarget::ProductAnnouncementScrollbar { grab_row_offset }, - }) = &self.state.drag - { - if let Some(offset_from_bottom) = self - .state - .product_announcement_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.state - .set_product_announcement_offset_from_bottom(offset_from_bottom); - } - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.state.drag = None; - } - MouseEventKind::ScrollUp => self.scroll_product_announcement(-3), - MouseEventKind::ScrollDown => self.scroll_product_announcement(3), - _ => {} - } - return true; - } - - if self.state.mode == Mode::Navigator { - match mouse.kind { - MouseEventKind::Moved => { - if let Some(idx) = self.state.navigator_row_index_at_from( - &self.terminal_runtimes, - mouse.column, - mouse.row, - ) { - self.state.navigator.selected = idx; - self.state - .ensure_navigator_selection_visible_from(&self.terminal_runtimes); - } - } - MouseEventKind::Down(MouseButton::Left) => { - if self - .state - .navigator_search_contains(mouse.column, mouse.row) - { - self.state.navigator.search_focused = true; - } else if let Some(idx) = self.state.navigator_row_index_at_from( - &self.terminal_runtimes, - mouse.column, - mouse.row, - ) { - self.state.navigator.selected = idx; - let target = self - .state - .navigator_rows_from(&self.terminal_runtimes) - .get(idx) - .map(|row| (row.target.clone(), row.is_workspace)); - if let Some((NavigatorTarget::Workspace { .. }, true)) = target { - if self.state.navigator_row_caret_at(mouse.column) { - self.state.toggle_selected_navigator_workspace_from( - &self.terminal_runtimes, - ); - } else { - self.state - .accept_navigator_selection_from(&self.terminal_runtimes); - } - } else { - self.state - .accept_navigator_selection_from(&self.terminal_runtimes); - } - } else if !self.state.navigator_popup_contains(mouse.column, mouse.row) { - leave_modal(&mut self.state); - } - } - MouseEventKind::ScrollUp => { - self.state.navigator.scroll = self.state.navigator.scroll.saturating_sub(3); - self.state - .align_navigator_selection_to_scroll_from(&self.terminal_runtimes); - } - MouseEventKind::ScrollDown => { - let viewport = self.state.navigator_body_rect().height as usize; - let max = self - .state - .navigator_max_scroll_from(&self.terminal_runtimes, viewport); - self.state.navigator.scroll = - self.state.navigator.scroll.saturating_add(3).min(max); - self.state - .align_navigator_selection_to_scroll_from(&self.terminal_runtimes); - } - _ => {} - } - return true; - } - - if self.state.mode == Mode::KeybindHelp { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) - if self - .state - .keybind_help_close_button_at(mouse.column, mouse.row) => - { - keybind_help_back(&mut self.state); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = self - .state - .keybind_help_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.state.drag = Some(DragState { - target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.state - .set_keybind_help_offset_from_bottom(offset_from_bottom); - } - } - } else { - let rect = self.state.keybind_help_popup_rect(); - let inside = mouse.column >= rect.x - && mouse.column < rect.x + rect.width - && mouse.row >= rect.y - && mouse.row < rect.y + rect.height; - if !inside { - leave_modal(&mut self.state); - } - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(DragState { - target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, - }) = &self.state.drag - { - if let Some(offset_from_bottom) = self - .state - .keybind_help_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.state - .set_keybind_help_offset_from_bottom(offset_from_bottom); - } - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.state.drag = None; - } - MouseEventKind::ScrollUp => self.state.scroll_keybind_help(-3), - MouseEventKind::ScrollDown => self.state.scroll_keybind_help(3), - _ => {} - } - return true; - } - - false - } -} - -impl AppState { - pub(super) fn onboarding_full_area(&self) -> Rect { - self.view.sidebar_rect.union(self.view.terminal_area) - } - - pub(crate) fn navigator_popup_rect(&self) -> Rect { - let area = self.onboarding_full_area(); - let margin_x = (area.width / 16).max(2); - let margin_y = (area.height / 10).max(1); - let width = area.width.saturating_sub(margin_x.saturating_mul(2)); - let height = area.height.saturating_sub(margin_y.saturating_mul(2)); - Rect::new( - area.x + margin_x, - area.y + margin_y, - width.max(4), - height.max(4), - ) - } - - pub(crate) fn navigator_inner_rect(&self) -> Rect { - Block::default() - .borders(Borders::ALL) - .inner(self.navigator_popup_rect()) - } - - pub(crate) fn navigator_search_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - Rect::new(inner.x, inner.y, inner.width, inner.height.min(1)) - } - - pub(crate) fn navigator_body_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - if inner.height <= 4 { - return Rect::default(); - } - Rect::new( - inner.x, - inner.y + 2, - inner.width, - inner.height.saturating_sub(4), - ) - } - - pub(crate) fn navigator_detail_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - Rect::new( - inner.x, - inner.y + inner.height.saturating_sub(2), - inner.width, - inner.height.min(1), - ) - } - - pub(crate) fn navigator_footer_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - Rect::new( - inner.x, - inner.y + inner.height.saturating_sub(1), - inner.width, - inner.height.min(1), - ) - } - - pub(crate) fn navigator_popup_contains(&self, col: u16, row: u16) -> bool { - rect_contains(self.navigator_popup_rect(), col, row) - } - - pub(crate) fn navigator_search_contains(&self, col: u16, row: u16) -> bool { - rect_contains(self.navigator_search_rect(), col, row) - } - - pub(crate) fn navigator_row_index_at_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - col: u16, - row: u16, - ) -> Option { - let body = self.navigator_body_rect(); - if !rect_contains(body, col, row) { - return None; - } - let line_idx = self - .navigator - .scroll - .saturating_add(row.saturating_sub(body.y) as usize); - let lines = crate::app::state::navigator_display_lines( - &self.navigator_rows_from(terminal_runtimes), - ); - match lines.get(line_idx) { - Some(crate::app::state::NavigatorDisplayLine::Row(idx)) => Some(*idx), - _ => None, - } - } - - pub(crate) fn navigator_row_caret_at(&self, col: u16) -> bool { - let body = self.navigator_body_rect(); - col <= body.x.saturating_add(3) - } - - pub(super) fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option { - let area = self.onboarding_full_area(); - let popup_w = popup_w.min(area.width.saturating_sub(4)); - let popup_h = popup_h.min(area.height.saturating_sub(2)); - if popup_w < 4 || popup_h < 4 { - return None; - } - let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; - let popup = Rect::new(popup_x, popup_y, popup_w, popup_h); - Some(Block::default().borders(Borders::ALL).inner(popup)) - } - - fn release_notes_modal_inner(&self) -> Option { - self.onboarding_modal_inner( - crate::ui::RELEASE_NOTES_MODAL_SIZE.0, - crate::ui::RELEASE_NOTES_MODAL_SIZE.1, - ) - } - - fn product_announcement_modal_inner(&self) -> Option { - self.onboarding_modal_inner( - crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, - crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1, - ) - } - - fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool { - let Some(inner) = self.release_notes_modal_inner() else { - return false; - }; - if inner.height < 4 || inner.width < 12 { - return false; - } - let button = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - col >= button.x - && col < button.x + button.width - && row >= button.y - && row < button.y + button.height - } - - pub(super) fn rename_modal_inner(&self) -> Option { - self.onboarding_modal_inner(56, 7) - } - - fn release_notes_body_rect(&self) -> Option { - let inner = self.release_notes_modal_inner()?; - if inner.height < 8 || inner.width < 4 { - return None; - } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) - } - - fn release_notes_scroll_metrics(&self) -> Option { - Some(crate::ui::release_notes_scroll_metrics( - self.release_notes.as_ref()?, - &self.update_install_command, - self.release_notes_body_rect()?, - &self.palette, - )) - } - - pub(crate) fn release_notes_max_scroll(&self) -> u16 { - self.release_notes_scroll_metrics() - .map(|metrics| metrics.max_offset_from_bottom as u16) - .unwrap_or(0) - } - - fn release_notes_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option { - let body = self.release_notes_body_rect()?; - let metrics = self.release_notes_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - if !(col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height) - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option { - let body = self.release_notes_body_rect()?; - let metrics = self.release_notes_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let max_scroll = self.release_notes_max_scroll() as usize; - if let Some(notes) = &mut self.release_notes { - notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; - } - } - - fn product_announcement_close_button_at(&self, col: u16, row: u16) -> bool { - let Some(inner) = self.product_announcement_modal_inner() else { - return false; - }; - if inner.height < 4 || inner.width < 12 { - return false; - } - let button = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - col >= button.x - && col < button.x + button.width - && row >= button.y - && row < button.y + button.height - } - - fn product_announcement_body_rect(&self) -> Option { - let inner = self.product_announcement_modal_inner()?; - if inner.height < 8 || inner.width < 4 { - return None; - } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) - } - - fn product_announcement_scroll_metrics(&self) -> Option { - Some(crate::ui::product_announcement_scroll_metrics( - self.product_announcement.as_ref()?, - self.product_announcement_body_rect()?, - &self.palette, - )) - } - - pub(crate) fn product_announcement_max_scroll(&self) -> u16 { - self.product_announcement_scroll_metrics() - .map(|metrics| metrics.max_offset_from_bottom as u16) - .unwrap_or(0) - } - - fn product_announcement_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option { - let body = self.product_announcement_body_rect()?; - let metrics = self.product_announcement_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - if !(col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height) - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn product_announcement_offset_for_drag_row( - &self, - row: u16, - grab_row_offset: u16, - ) -> Option { - let body = self.product_announcement_body_rect()?; - let metrics = self.product_announcement_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - fn set_product_announcement_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let max_scroll = self.product_announcement_max_scroll() as usize; - if let Some(announcement) = &mut self.product_announcement { - announcement.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; - } - } - - pub(super) fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) { - if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - return; - } - - let Some(inner) = self.onboarding_modal_inner(64, 16) else { - return; - }; - let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) - .actions - .unwrap_or_default(); - let button = crate::ui::onboarding_welcome_continue_rect(actions); - if modal_action_from_buttons(mouse.column, mouse.row, &[(button, ModalAction::Continue)]) - == Some(ModalAction::Continue) - { - self.request_complete_onboarding = true; - } - } - - pub(super) fn keybind_help_popup_rect(&self) -> Rect { - crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default() - } - - fn keybind_help_modal_inner(&self) -> Option { - self.onboarding_modal_inner(76, 22) - } - - fn keybind_help_close_button_at(&self, col: u16, row: u16) -> bool { - let Some(inner) = self.keybind_help_modal_inner() else { - return false; - }; - if inner.height < 4 || inner.width < 12 { - return false; - } - let button = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - col >= button.x - && col < button.x + button.width - && row >= button.y - && row < button.y + button.height - } - - fn keybind_help_body_rect(&self) -> Option { - let inner = self.keybind_help_modal_inner()?; - if inner.height < 6 || inner.width < 4 { - return None; - } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) - } - - fn keybind_help_scroll_metrics(&self) -> Option { - let body = self.keybind_help_body_rect()?; - let viewport_rows = body.height.max(1) as usize; - let wrap_width = body.width.max(1) as usize; - let total_rows = crate::ui::keybind_help_lines(self) - .into_iter() - .map(|(width, _)| width.max(1).div_ceil(wrap_width)) - .sum::(); - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - Some(crate::pane::ScrollMetrics { - offset_from_bottom: max_offset_from_bottom - .saturating_sub(self.keybind_help.scroll as usize), - max_offset_from_bottom, - viewport_rows, - }) - } - - fn keybind_help_scrollbar_target_at(&self, col: u16, row: u16) -> Option { - let body = self.keybind_help_body_rect()?; - let metrics = self.keybind_help_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - if !(col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height) - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn keybind_help_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option { - let body = self.keybind_help_body_rect()?; - let metrics = self.keybind_help_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - pub(crate) fn keybind_help_max_scroll(&self) -> u16 { - self.keybind_help_scroll_metrics() - .map(|metrics| metrics.max_offset_from_bottom as u16) - .unwrap_or(0) - } - - fn set_keybind_help_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let max_scroll = self.keybind_help_max_scroll() as usize; - self.keybind_help.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; - } - - pub(super) fn scroll_keybind_help(&mut self, delta: i16) { - let max_scroll = self.keybind_help_max_scroll(); - let current = self.keybind_help.scroll as i16; - self.keybind_help.scroll = current.saturating_add(delta).clamp(0, max_scroll as i16) as u16; - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{MouseButton, MouseEventKind}; - use ratatui::layout::Rect; - - use super::super::{app_for_mouse_test, mouse}; - use super::*; - - #[test] - fn clicking_keybind_help_close_button_closes_overlay() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::KeybindHelp; - - let rect = app.state.keybind_help_popup_rect(); - let inner = Rect::new( - rect.x + 1, - rect.y + 1, - rect.width.saturating_sub(2), - rect.height.saturating_sub(2), - ); - let close = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - close.x, - close.y, - )); - - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn clicking_keybind_help_back_button_leaves_help_open() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::KeybindHelp; - app.state.keybind_help.search_focused = true; - app.state.keybind_help.query = "work".into(); - - let rect = app.state.keybind_help_popup_rect(); - let inner = Rect::new( - rect.x + 1, - rect.y + 1, - rect.width.saturating_sub(2), - rect.height.saturating_sub(2), - ); - let back = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - back.x, - back.y, - )); - - assert_eq!(app.state.mode, Mode::KeybindHelp); - assert!(!app.state.keybind_help.search_focused); - assert!(app.state.keybind_help.query.is_empty()); - } - - #[test] - fn onboarding_hover_does_not_change_selection() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::Onboarding; - - let inner = app.state.onboarding_modal_inner(64, 16).unwrap(); - let content = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1).content; - app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y)); - - assert!(!app.state.request_complete_onboarding); - } - - #[test] - fn onboarding_click_continue_requests_completion() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::Onboarding; - - let inner = app.state.onboarding_modal_inner(64, 16).unwrap(); - let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) - .actions - .unwrap(); - let continue_rect = crate::ui::onboarding_welcome_continue_rect(actions); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - continue_rect.x, - continue_rect.y, - )); - - assert!(app.state.request_complete_onboarding); - } - - #[test] - fn release_notes_preview_scrollbar_uses_full_content_body() { - let mut app = app_for_mouse_test(); - app.state.view.sidebar_rect = Rect::new(0, 0, 24, 16); - app.state.view.terminal_area = Rect::new(24, 0, 96, 16); - app.state.release_notes = Some(crate::app::state::ReleaseNotesState { - version: "9.9.9".into(), - body: "### Added\n- Custom command keybindings now accept an optional description field.\n\n### Fixed\n- Sidebar Git status refresh now deduplicates workspaces.\n- Large restored sessions no longer leave panes without shells after startup.\n- Pane shutdown no longer warns after the direct child has already exited.\n- Closing the last pane or tab in a parent worktree workspace now shows the existing confirmation before closing the whole worktree group.\n- Update prompts, toasts, and docs now distinguish installing a new binary from stopping or reattaching a running Herdr session to use it." - .into(), - scroll: 0, - preview: true, - }); - app.state.update_install_command = "brew update && brew upgrade herdr".into(); - - let inner = app.state.release_notes_modal_inner().unwrap(); - let expected_body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content; - let body = app.state.release_notes_body_rect().unwrap(); - - assert_eq!(body, expected_body); - - let metrics = app.state.release_notes_scroll_metrics().unwrap(); - assert_eq!(metrics.viewport_rows, body.height as usize); - assert!(metrics.max_offset_from_bottom > 0); - - let track = crate::ui::release_notes_scrollbar_rect(body, metrics).unwrap(); - assert_eq!(track.y, body.y); - assert!(matches!( - app.state - .release_notes_scrollbar_target_at(track.x, track.y), - Some(ScrollbarClickTarget::Thumb { .. } | ScrollbarClickTarget::Track { .. }) - )); - } -} diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs deleted file mode 100644 index a66509f0..00000000 --- a/src/app/input/selection.rs +++ /dev/null @@ -1,306 +0,0 @@ -use crossterm::event::{MouseEvent, MouseEventKind}; - -use crate::{ - app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection}, - terminal::TerminalRuntimeRegistry, -}; - -impl AppState { - pub(crate) fn update_selection_cursor( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - screen_col: u16, - screen_row: u16, - ) { - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - if let Some(selection) = self.selection.as_mut() { - selection.drag(screen_col, screen_row, info.inner_rect, metrics); - } - } - - fn selection_edge_scroll_lines(distance: u16) -> usize { - usize::from(distance).saturating_mul(3).clamp(3, 15) - } - - pub(super) fn update_selection_drag( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - screen_col: u16, - screen_row: u16, - ) { - let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else { - return; - }; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - - let top = info.inner_rect.y; - let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1); - - // Only activate autoscroll when the user is actively dragging. - // An anchored click in the hot zone should not start the timer. - // Check before advancing the cursor: if already Dragging from a prior - // event, it stays true. If Anchored, the mouse must have moved away - // from the anchor cell for this to count as a real drag. - let was_dragging = self.selection.as_ref().is_some_and(|s| s.is_dragging()); - let anchor_differs_from_mouse = self.selection.as_ref().is_some_and(|s| { - // Convert anchor to screen coords for comparison. - // Anchor is stored in absolute row; for a simple screen - // comparison, check whether the mouse is on a different - // cell than the anchor's screen position. - let (ar, ac) = s.anchor_screen_pos( - info.inner_rect, - self.pane_scroll_metrics(terminal_runtimes, s.pane_id), - ); - ar != screen_row || ac != screen_col - }); - let is_dragging = was_dragging || anchor_differs_from_mouse; - - // Advance the selection cursor. - self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); - - // If the mouse is on a different cell than the anchor but drag() - // didn't transition (cursor clamped to edge == anchor), force - // Dragging so the selection becomes visible and autoscroll can run. - if is_dragging { - if let Some(sel) = self.selection.as_mut() { - if sel.is_just_click() { - sel.force_dragging(); - } - } - } - - if screen_row < top { - // Cursor above pane — immediate scroll + set autoscroll state - if is_dragging { - self.scroll_pane_up( - terminal_runtimes, - pane_id, - Self::selection_edge_scroll_lines(top - screen_row), - ); - // Re-advance cursor after scroll so it reflects the new viewport position - self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); - self.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Up, - last_mouse_screen_col: screen_col, - last_mouse_screen_row: screen_row, - inner_rect: info.inner_rect, - }); - } - } else if screen_row > bottom { - // Cursor below pane — immediate scroll + set autoscroll state - if is_dragging { - self.scroll_pane_down( - terminal_runtimes, - pane_id, - Self::selection_edge_scroll_lines(screen_row - bottom), - ); - // Re-advance cursor after scroll so it reflects the new viewport position - self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); - self.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Down, - last_mouse_screen_col: screen_col, - last_mouse_screen_row: screen_row, - inner_rect: info.inner_rect, - }); - } - } else if screen_row == top { - // Hot zone: top edge row — no immediate scroll, set autoscroll state - if is_dragging { - self.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Up, - last_mouse_screen_col: screen_col, - last_mouse_screen_row: screen_row, - inner_rect: info.inner_rect, - }); - } else { - self.selection_autoscroll = None; - } - } else if screen_row == bottom { - // Hot zone: bottom edge row — no immediate scroll, set autoscroll state - if is_dragging { - self.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Down, - last_mouse_screen_col: screen_col, - last_mouse_screen_row: screen_row, - inner_rect: info.inner_rect, - }); - } else { - self.selection_autoscroll = None; - } - } else { - // Safe zone: inside pane, not on edge rows — clear autoscroll - self.selection_autoscroll = None; - } - } - - pub(super) fn scroll_selection_with_wheel( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - mouse: MouseEvent, - ) -> bool { - let lines_per_notch = self.mouse_scroll_lines; - - let Some(selection) = self.selection.as_ref() else { - return false; - }; - if !selection.is_in_progress() { - return false; - } - let pane_id = selection.pane_id; - self.focus_pane(pane_id); - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll_pane_up(terminal_runtimes, pane_id, lines_per_notch) - } - MouseEventKind::ScrollDown => { - self.scroll_pane_down(terminal_runtimes, pane_id, lines_per_notch) - } - _ => return false, - } - self.update_selection_cursor(terminal_runtimes, pane_id, mouse.column, mouse.row); - true - } -} - -#[cfg(test)] -mod autoscroll_tests { - use super::*; - use crate::layout::PaneInfo; - use crate::terminal::TerminalRuntimeRegistry; - use crate::workspace::Workspace; - use ratatui::layout::Rect; - - /// Build an AppState with one workspace/pane and pane_infos populated - /// so pane_info_by_id works. Returns (state, pane_id). - fn make_state_with_pane() -> (AppState, crate::layout::PaneId) { - let mut state = AppState::test_new(); - let ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - state.workspaces.push(ws); - state.active = Some(0); - state.view.pane_infos.push(PaneInfo { - id: pane_id, - rect: Rect::new(0, 0, 80, 24), - inner_rect: Rect::new(0, 0, 80, 24), - scrollbar_rect: None, - borders: ratatui::widgets::Borders::NONE, - is_focused: true, - }); - (state, pane_id) - } - - #[test] - fn above_pane_sets_autoscroll_up() { - // Build state with pane starting at row 5 so we can drag above it - let mut state = AppState::test_new(); - let ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - state.workspaces.push(ws); - state.active = Some(0); - state.view.pane_infos.push(PaneInfo { - id: pane_id, - rect: Rect::new(0, 5, 80, 24), - inner_rect: Rect::new(0, 5, 80, 24), - scrollbar_rect: None, - borders: ratatui::widgets::Borders::NONE, - is_focused: true, - }); - // Anchor at (5, 10), drag to different cell above pane - let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); - sel.drag(4, 5, Rect::new(0, 5, 80, 24), None); - state.selection = Some(sel); - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 5, 4); - let autoscroll = state.selection_autoscroll.as_ref().unwrap(); - assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); - } - - #[test] - fn top_hot_zone_sets_autoscroll_up_on_drag() { - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at (5, 10), drag to top edge row (row 0) — different cell - let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); - sel.drag(0, 0, Rect::new(0, 0, 80, 24), None); - state.selection = Some(sel); - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 0); - let autoscroll = state.selection_autoscroll.as_ref().unwrap(); - assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); - } - - #[test] - fn top_hot_zone_clears_autoscroll_on_click() { - // An anchored click on the top edge row should NOT start autoscroll. - let (mut state, pane_id) = make_state_with_pane(); - state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); - // Same-cell drag on top edge row — still anchored - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 0); - assert!(state.selection_autoscroll.is_none()); - } - - #[test] - fn bottom_hot_zone_sets_autoscroll_down_on_drag() { - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at (0, 0), drag to bottom edge row (row 23) — different cell - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - sel.drag(23, 0, Rect::new(0, 0, 80, 24), None); - state.selection = Some(sel); - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 23); - let autoscroll = state.selection_autoscroll.as_ref().unwrap(); - assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); - } - - #[test] - fn bottom_hot_zone_clears_autoscroll_on_click() { - // An anchored click on the bottom edge row should NOT start autoscroll. - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at bottom edge row - state.selection = Some(crate::selection::Selection::anchor(pane_id, 23, 0, None)); - // Same-cell drag — still anchored - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 23); - assert!(state.selection_autoscroll.is_none()); - } - - #[test] - fn below_pane_sets_autoscroll_down_on_drag() { - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at (0, 0), drag to different cell below pane - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - sel.drag(5, 5, Rect::new(0, 0, 80, 24), None); - state.selection = Some(sel); - // Drag cursor one row below the pane bottom - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 24); - let autoscroll = state.selection_autoscroll.as_ref().unwrap(); - assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); - } - - #[test] - fn safe_zone_clears_autoscroll() { - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at (0, 0), drag to (5, 5) so it's truly dragging - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - sel.drag(5, 5, Rect::new(0, 0, 80, 24), None); - state.selection = Some(sel); - // Set autoscroll first - state.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 5, - last_mouse_screen_row: 23, - inner_rect: Rect::new(0, 0, 80, 24), - }); - // Move cursor into safe zone (middle of pane, not on edge rows) - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 5, 12); - assert!(state.selection_autoscroll.is_none()); - } -} diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs deleted file mode 100644 index fb1a7d75..00000000 --- a/src/app/input/settings.rs +++ /dev/null @@ -1,693 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; -use ratatui::layout::Rect; - -use crate::{ - app::{ - state::{AppState, SettingsSection, THEME_NAMES}, - App, Mode, - }, - config::{StatusIndicatorStyle, ToastDelivery}, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -// The shared `Save` verb is semantic: these actions persist settings. -#[allow(clippy::enum_variant_names)] -pub(super) enum SettingsAction { - SaveTheme(String), - SaveStatusIndicators(StatusIndicatorStyle), - SaveSound(bool), - SaveToastDelivery(ToastDelivery), - SaveAgentBorderLabels(bool), - InstallRecommendedIntegrations, -} - -impl App { - pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) { - let previous_section = self.state.settings.section; - if let Some(action) = update_settings_state(&mut self.state, key) { - match action { - SettingsAction::SaveTheme(name) => self.save_theme(&name), - SettingsAction::SaveStatusIndicators(style) => self.save_status_indicators(style), - SettingsAction::SaveSound(enabled) => self.save_sound(enabled), - SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery), - SettingsAction::SaveAgentBorderLabels(enabled) => { - self.save_agent_border_labels(enabled) - } - SettingsAction::InstallRecommendedIntegrations => { - self.install_recommended_integrations() - } - } - } - if previous_section != SettingsSection::Integrations - && self.state.settings.section == SettingsSection::Integrations - { - self.refresh_integration_recommendations(); - } - } -} - -fn normalize_theme_name(name: &str) -> String { - name.to_lowercase().replace([' ', '_'], "-") -} - -fn current_theme_index(theme_name: &str) -> usize { - let normalized = normalize_theme_name(theme_name); - THEME_NAMES - .iter() - .position(|name| normalize_theme_name(name) == normalized) - .unwrap_or(0) -} - -fn status_indicator_index(style: StatusIndicatorStyle) -> usize { - match style { - StatusIndicatorStyle::Dots => 0, - StatusIndicatorStyle::Symbols => 1, - } -} - -fn status_indicator_for_index(idx: usize) -> StatusIndicatorStyle { - if idx == 0 { - StatusIndicatorStyle::Dots - } else { - StatusIndicatorStyle::Symbols - } -} - -fn toast_delivery_index(delivery: ToastDelivery) -> usize { - match delivery { - ToastDelivery::Off => 0, - ToastDelivery::Herdr => 1, - ToastDelivery::Terminal => 2, - ToastDelivery::System => 3, - } -} - -fn toast_delivery_for_index(idx: usize) -> ToastDelivery { - match idx { - 0 => ToastDelivery::Off, - 1 => ToastDelivery::Herdr, - 2 => ToastDelivery::Terminal, - _ => ToastDelivery::System, - } -} - -fn preview_selected_theme(state: &mut AppState) { - use crate::app::state::Palette; - - let name = THEME_NAMES[state.settings.list.selected]; - if let Some(mut palette) = Palette::from_name(name) { - if let Some(custom) = &state.theme_runtime.custom { - palette = palette.with_overrides(custom); - } - if let Some(accent) = &state.theme_runtime.legacy_accent { - palette.accent = crate::config::parse_color(accent); - } - state.palette = palette; - state.theme_name = name.to_string(); - } -} - -fn cancel_settings(state: &mut AppState) { - if let Some(palette) = state.settings.original_palette.take() { - state.palette = palette; - } - if let Some(theme_name) = state.settings.original_theme.take() { - state.theme_name = theme_name; - } - super::modal::leave_modal(state); -} - -fn integrations_need_install(state: &AppState) -> bool { - state - .integration_recommendations - .iter() - .any(crate::integration::IntegrationRecommendation::needs_install) -} - -fn apply_settings(state: &mut AppState) -> Option { - match state.settings.section { - SettingsSection::Theme => { - let theme_name = state.theme_name.clone(); - state.settings.original_palette = None; - state.settings.original_theme = None; - super::modal::leave_modal(state); - Some(SettingsAction::SaveTheme(theme_name)) - } - SettingsSection::Integrations if integrations_need_install(state) => { - Some(SettingsAction::InstallRecommendedIntegrations) - } - SettingsSection::Integrations => None, - _ => { - super::modal::leave_modal(state); - None - } - } -} - -pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option { - match state.settings.section { - SettingsSection::Theme => match key.code { - KeyCode::Up | KeyCode::Char('k') => { - let previous = state.settings.list.selected; - state.settings.list.move_prev(); - if state.settings.list.selected != previous { - preview_selected_theme(state); - } - } - KeyCode::Down | KeyCode::Char('j') => { - let previous = state.settings.list.selected; - state.settings.list.move_next(THEME_NAMES.len()); - if state.settings.list.selected != previous { - preview_selected_theme(state); - } - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Indicators; - state.settings.list.selected = status_indicator_index(state.status_indicators); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Integrations; - state.settings.list.selected = 0; - } - _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { - Some(super::modal::ModalAction::Apply) => return apply_settings(state), - Some(super::modal::ModalAction::Close) => cancel_settings(state), - _ => {} - }, - }, - SettingsSection::Indicators => match key.code { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.settings.list.selected = 1 - state.settings.list.selected.min(1); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let style = status_indicator_for_index(state.settings.list.selected); - return Some(SettingsAction::SaveStatusIndicators(style)); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Sound; - state.settings.list.selected = usize::from(!state.sound_enabled()); - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::Sound => match key.code { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.settings.list.selected = 1 - state.settings.list.selected.min(1); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let enabled = state.settings.list.selected == 0; - return Some(SettingsAction::SaveSound(enabled)); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Toast; - state.settings.list.selected = toast_delivery_index(state.toast_delivery()); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Indicators; - state.settings.list.selected = status_indicator_index(state.status_indicators); - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::Toast => match key.code { - KeyCode::Up | KeyCode::Char('k') => state.settings.list.move_prev(), - KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(4), - KeyCode::Enter | KeyCode::Char(' ') => { - let delivery = toast_delivery_for_index(state.settings.list.selected); - return Some(SettingsAction::SaveToastDelivery(delivery)); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Sound; - state.settings.list.selected = usize::from(!state.sound_enabled()); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::PaneLabels; - state.settings.list.selected = usize::from(!state.agent_border_labels_enabled()); - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::PaneLabels => match key.code { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.settings.list.selected = 1 - state.settings.list.selected.min(1); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let enabled = state.settings.list.selected == 0; - return Some(SettingsAction::SaveAgentBorderLabels(enabled)); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Toast; - state.settings.list.selected = toast_delivery_index(state.toast_delivery()); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Integrations; - state.settings.list.selected = 0; - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::Integrations => match key.code { - KeyCode::Enter | KeyCode::Char(' ') if integrations_need_install(state) => { - return Some(SettingsAction::InstallRecommendedIntegrations); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::PaneLabels; - state.settings.list.selected = usize::from(!state.agent_border_labels_enabled()); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); - } - _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { - Some(super::modal::ModalAction::Apply) => return apply_settings(state), - Some(super::modal::ModalAction::Close) => cancel_settings(state), - _ => {} - }, - }, - } - - None -} - -pub(crate) fn open_settings(state: &mut AppState) { - open_settings_at(state, SettingsSection::Theme); -} - -pub(crate) fn open_settings_at(state: &mut AppState, section: SettingsSection) { - state.integration_install_messages.clear(); - state.settings.original_palette = Some(state.palette.clone()); - state.settings.original_theme = Some(state.theme_name.clone()); - state.settings.section = section; - state.settings.list.selected = match section { - SettingsSection::Theme => current_theme_index(&state.theme_name), - SettingsSection::Indicators => status_indicator_index(state.status_indicators), - SettingsSection::Sound => usize::from(!state.sound_enabled()), - SettingsSection::Toast => toast_delivery_index(state.toast_delivery()), - SettingsSection::PaneLabels => usize::from(!state.agent_border_labels_enabled()), - SettingsSection::Integrations => 0, - }; - state.mode = Mode::Settings; -} - -impl AppState { - fn settings_popup_rect(&self) -> Rect { - crate::ui::centered_popup_rect( - self.screen_rect(), - crate::ui::SETTINGS_POPUP_WIDTH, - crate::ui::settings_popup_height(self), - ) - .unwrap_or_default() - } - - fn settings_inner_rect(&self) -> Rect { - let popup = self.settings_popup_rect(); - Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ) - } - - fn settings_tab_at(&self, col: u16, row: u16) -> Option { - let inner = self.settings_inner_rect(); - let tab_y = inner.y + 1; - if row != tab_y { - return None; - } - let mut x = inner.x; - for section in SettingsSection::ALL { - let badge_width = if self.settings_section_has_badge(*section) { - 2 - } else { - 0 - }; - let width = section.label().len() as u16 + 2 + badge_width; - if col >= x && col < x + width { - return Some(*section); - } - x += width + 1; - } - None - } - - pub(crate) fn settings_content_rect(&self) -> Rect { - let inner = self.settings_inner_rect(); - crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content - } - - fn settings_list_index_at(&self, col: u16, row: u16) -> Option { - let area = self.settings_content_rect(); - if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width - { - return None; - } - - match self.settings.section { - SettingsSection::Theme => { - let max_visible = area.height as usize; - let scroll = if self.settings.list.selected >= max_visible { - self.settings.list.selected - max_visible + 1 - } else { - 0 - }; - let idx = scroll + (row - area.y) as usize; - (idx < THEME_NAMES.len()).then_some(idx) - } - SettingsSection::Indicators | SettingsSection::Sound => { - let list_y = area.y + 3; - if row >= list_y && row < list_y + 2 { - Some((row - list_y) as usize) - } else { - None - } - } - SettingsSection::Toast => { - let list_y = area.y + 3; - if row >= list_y && row < list_y + 8 { - Some(((row - list_y) / 2) as usize) - } else { - None - } - } - SettingsSection::PaneLabels => { - let list_y = area.y + 3; - if row >= list_y && row < list_y + 2 { - Some((row - list_y) as usize) - } else { - None - } - } - SettingsSection::Integrations => None, - } - } - - pub(super) fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) { - self.settings.section = section; - self.settings.list.select(match section { - SettingsSection::Theme => current_theme_index(&self.theme_name), - SettingsSection::Indicators => { - status_indicator_index(self.status_indicators) - } - SettingsSection::Sound => usize::from(!self.sound_enabled()), - SettingsSection::Toast => toast_delivery_index(self.toast_delivery()), - SettingsSection::PaneLabels => { - usize::from(!self.agent_border_labels_enabled()) - } - SettingsSection::Integrations => 0, - }); - return None; - } - if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) { - self.settings.list.select(idx); - return match self.settings.section { - SettingsSection::Theme => { - preview_selected_theme(self); - None - } - SettingsSection::Indicators => Some(SettingsAction::SaveStatusIndicators( - status_indicator_for_index(idx), - )), - SettingsSection::Sound => { - let enabled = idx == 0; - Some(SettingsAction::SaveSound(enabled)) - } - SettingsSection::Toast => { - let delivery = toast_delivery_for_index(idx); - Some(SettingsAction::SaveToastDelivery(delivery)) - } - SettingsSection::PaneLabels => { - let enabled = idx == 0; - Some(SettingsAction::SaveAgentBorderLabels(enabled)) - } - SettingsSection::Integrations => None, - }; - } - - let inner = self.settings_inner_rect(); - let show_primary = crate::ui::settings_show_primary_action(self); - let (apply, close) = - crate::ui::settings_button_rects(inner, self.settings.section, show_primary); - let mut buttons = vec![(close, super::modal::ModalAction::Close)]; - if let Some(apply) = apply { - buttons.insert(0, (apply, super::modal::ModalAction::Apply)); - } - match super::modal::modal_action_from_buttons(mouse.column, mouse.row, &buttons) { - Some(super::modal::ModalAction::Apply) => apply_settings(self), - Some(super::modal::ModalAction::Close) => { - cancel_settings(self); - None - } - _ => { - cancel_settings(self); - None - } - } - } - _ => None, - } - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; - - use super::super::{app_for_mouse_test, mouse, state_with_workspaces}; - use super::*; - - #[test] - fn settings_cancel_restores_previewed_theme_from_other_sections() { - let mut state = state_with_workspaces(&["test"]); - let original_palette = state.palette.clone(); - let original_theme = state.theme_name.clone(); - - open_settings(&mut state); - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - assert_ne!(state.theme_name, original_theme); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - ); - assert_eq!( - state.settings.section, - crate::app::state::SettingsSection::Indicators - ); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert_eq!(state.theme_name, original_theme); - assert_eq!(state.palette.accent, original_palette.accent); - assert_eq!(state.palette.panel_bg, original_palette.panel_bg); - } - - #[test] - fn settings_indicator_choice_returns_save_action() { - let mut state = state_with_workspaces(&["test"]); - open_settings_at(&mut state, SettingsSection::Indicators); - state.settings.list.selected = 1; - - let action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!( - action, - Some(SettingsAction::SaveStatusIndicators( - StatusIndicatorStyle::Symbols - )) - ); - assert_eq!(state.status_indicators, StatusIndicatorStyle::Dots); - assert_eq!(state.mode, Mode::Settings); - } - - #[test] - fn settings_sound_toggle_returns_save_action() { - let mut state = state_with_workspaces(&["test"]); - open_settings(&mut state); - state.settings.section = crate::app::state::SettingsSection::Sound; - state.settings.list.selected = 0; - - let action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(action, Some(SettingsAction::SaveSound(true))); - assert!(!state.sound.enabled); - assert_eq!(state.mode, Mode::Settings); - } - - #[test] - fn settings_tab_cycle_wraps_after_integrations() { - let mut state = state_with_workspaces(&["test"]); - open_settings_at(&mut state, SettingsSection::PaneLabels); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::Integrations); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::Theme); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::Integrations); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::PaneLabels); - } - - #[test] - fn integrations_enter_does_nothing_when_nothing_needs_install() { - let mut state = state_with_workspaces(&["test"]); - open_settings_at(&mut state, SettingsSection::Integrations); - - let enter_action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(enter_action, None); - - let space_action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Char(' '), KeyModifiers::empty()), - ); - assert_eq!(space_action, None); - } - - #[test] - fn settings_hover_does_not_change_selection() { - let mut app = app_for_mouse_test(); - open_settings(&mut app.state); - app.state.settings.list.select(0); - - let area = app.state.settings_content_rect(); - app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2)); - - assert_eq!(app.state.settings.list.selected, 0); - } - - #[test] - fn integration_update_badge_only_tracks_outdated_recommendations() { - let mut state = state_with_workspaces(&["test"]); - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::NotInstalled, - true, - )]; - assert!(!state.integration_updates_available()); - - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::NotInstalled, - false, - )]; - assert!(!state.integration_updates_available()); - - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::Current, - true, - )]; - assert!(!state.integration_updates_available()); - - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::Outdated, - true, - )]; - assert!(state.integration_updates_available()); - } - - #[test] - fn settings_tab_hit_area_includes_integration_update_badge() { - let mut state = state_with_workspaces(&["test"]); - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::Outdated, - true, - )]; - open_settings(&mut state); - - let inner = state.settings_inner_rect(); - let tab_y = inner.y + 1; - let integrations_idx = SettingsSection::ALL - .iter() - .position(|section| *section == SettingsSection::Integrations) - .expect("integrations section should be present"); - let integrations_x = inner.x - + SettingsSection::ALL[..integrations_idx] - .iter() - .map(|section| { - let badge_width = if state.settings_section_has_badge(*section) { - 2 - } else { - 0 - }; - section.label().len() as u16 + 3 + badge_width - }) - .sum::(); - let dotted_width = SettingsSection::Integrations.label().len() as u16 + 4; - - assert_eq!( - state.settings_tab_at(integrations_x + dotted_width - 1, tab_y), - Some(SettingsSection::Integrations) - ); - } - - fn integration_recommendation( - state: crate::integration::IntegrationStatusKind, - available: bool, - ) -> crate::integration::IntegrationRecommendation { - crate::integration::IntegrationRecommendation { - target: crate::api::schema::IntegrationTarget::Claude, - label: "claude", - command: "claude", - available, - path: std::path::PathBuf::from("/tmp/herdr-test-integration"), - state, - } - } -} diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs deleted file mode 100644 index b745ab9a..00000000 --- a/src/app/input/sidebar.rs +++ /dev/null @@ -1,1935 +0,0 @@ -use ratatui::layout::Rect; - -use crate::app::state::{AppState, ViewLayout}; - -use super::ScrollbarClickTarget; - -impl AppState { - pub(super) fn workspace_list_rect(&self) -> Rect { - let sidebar = self.view.sidebar_rect; - if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { - return Rect::default(); - } - crate::ui::workspace_list_rect(sidebar, self.sidebar_section_split) - } - - pub(super) fn agent_panel_rect(&self) -> Rect { - let sidebar = self.view.sidebar_rect; - if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { - return Rect::default(); - } - let (_, detail_area) = - crate::ui::expanded_sidebar_sections(sidebar, self.sidebar_section_split); - detail_area - } - - pub(super) fn workspace_list_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option { - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - let track = crate::ui::workspace_list_scrollbar_rect(self, area)?; - if col < track.x - || col >= track.x + track.width - || row < track.y - || row >= track.y + track.height - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - pub(super) fn workspace_list_offset_for_drag_row( - &self, - row: u16, - grab_row_offset: u16, - ) -> Option { - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - let track = crate::ui::workspace_list_scrollbar_rect(self, area)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - pub(super) fn set_workspace_list_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - self.workspace_scroll = metrics - .max_offset_from_bottom - .saturating_sub(offset_from_bottom); - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - } - - pub(super) fn scroll_workspace_list(&mut self, delta: i16) { - if delta.is_negative() { - self.workspace_scroll = self - .workspace_scroll - .saturating_sub(delta.unsigned_abs() as usize); - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - return; - } - - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - self.workspace_scroll = self - .workspace_scroll - .saturating_add(delta as usize) - .min(metrics.max_offset_from_bottom); - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - } - - pub(super) fn agent_panel_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option { - let area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, area); - let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; - if col < track.x - || col >= track.x + track.width - || row < track.y - || row >= track.y + track.height - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - pub(super) fn agent_panel_offset_for_drag_row( - &self, - row: u16, - grab_row_offset: u16, - ) -> Option { - let area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, area); - let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - pub(super) fn set_agent_panel_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, area); - self.agent_panel_scroll = metrics - .max_offset_from_bottom - .saturating_sub(offset_from_bottom); - } - - pub(super) fn scroll_agent_panel(&mut self, delta: i16) { - let area = self.agent_panel_rect(); - let max_scroll = crate::ui::agent_panel_scroll_metrics(self, area).max_offset_from_bottom; - if delta.is_negative() { - self.agent_panel_scroll = self - .agent_panel_scroll - .saturating_sub(delta.unsigned_abs() as usize); - } else { - self.agent_panel_scroll = self - .agent_panel_scroll - .saturating_add(delta as usize) - .min(max_scroll); - } - } - - pub(crate) fn sidebar_footer_rect(&self) -> Rect { - let ws_area = self.workspace_list_rect(); - if ws_area == Rect::default() { - return Rect::default(); - } - let y = ws_area.y + ws_area.height.saturating_sub(1); - Rect::new(ws_area.x, y, ws_area.width, 1) - } - - pub(crate) fn sidebar_new_button_rect(&self) -> Rect { - let footer = self.sidebar_footer_rect(); - let width = 5u16.min(footer.width.max(1)); - Rect::new(footer.x, footer.y, width, footer.height) - } - - pub(crate) fn global_launcher_rect(&self) -> Rect { - if self.view.layout == ViewLayout::Mobile { - return self.view.mobile_menu_hit_area; - } - - let footer = self.sidebar_footer_rect(); - let width = if self.global_menu_attention_badge_visible() { - 8 - } else { - 6 - } - .min(footer.width.max(1)); - let x = footer.x + footer.width.saturating_sub(width); - Rect::new(x, footer.y, width, footer.height) - } - - pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> { - let mut labels = vec!["settings", "keybinds", "reload config"]; - if self.update_available.is_some() { - labels.push("update ready"); - } else if self.latest_release_notes_available { - labels.push("what's new"); - } - labels.push("detach"); - labels - } - - pub(crate) fn global_menu_rect(&self) -> Rect { - let screen = self.screen_rect(); - let launcher = self.global_launcher_rect(); - let labels = self.global_menu_labels(); - let content_width = labels - .iter() - .map(|label| { - let badge_width = if self.global_menu_item_has_badge(label) { - 2 - } else { - 0 - }; - label.chars().count() as u16 + badge_width - }) - .max() - .unwrap_or(8) - .saturating_add(2); - let menu_w = content_width.saturating_add(2).min(screen.width.max(1)); - let menu_h = (labels.len() as u16 + 2).min(screen.height.max(1)); - let max_x = screen.x + screen.width.saturating_sub(menu_w); - let desired_x = launcher.x + launcher.width.saturating_sub(menu_w); - let x = desired_x.min(max_x); - let y = launcher.y.saturating_sub(menu_h); - Rect::new(x, y, menu_w, menu_h) - } - - pub(super) fn on_sidebar_divider(&self, col: u16, row: u16) -> bool { - if self.sidebar_collapsed { - return false; - } - let sidebar = self.view.sidebar_rect; - let toggle = crate::ui::expanded_sidebar_toggle_rect(sidebar); - let on_toggle = toggle.width > 0 - && col >= toggle.x - && col < toggle.x + toggle.width - && row >= toggle.y - && row < toggle.y + toggle.height; - sidebar.width > 0 - && !on_toggle - && col == sidebar.x + sidebar.width.saturating_sub(1) - && row >= sidebar.y - && row < sidebar.y + sidebar.height - } - - pub(super) fn on_sidebar_toggle(&self, col: u16, row: u16) -> bool { - let rect = if self.sidebar_collapsed { - crate::ui::collapsed_sidebar_toggle_rect(self.view.sidebar_rect) - } else { - crate::ui::expanded_sidebar_toggle_rect(self.view.sidebar_rect) - }; - rect.width > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height - } - - pub(super) fn set_manual_sidebar_width(&mut self, divider_col: u16) { - let sidebar = self.view.sidebar_rect; - let width = divider_col.saturating_sub(sidebar.x).saturating_add(1); - self.sidebar_width = width.clamp(self.sidebar_min_width, self.sidebar_max_width); - self.sidebar_width_source = crate::app::state::SidebarWidthSource::Manual; - self.mark_session_dirty(); - } - - pub(super) fn on_sidebar_section_divider(&self, col: u16, row: u16) -> bool { - if self.sidebar_collapsed { - return false; - } - let rect = crate::ui::sidebar_section_divider_rect( - self.view.sidebar_rect, - self.sidebar_section_split, - ); - rect.width > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height - } - - pub(super) fn set_sidebar_section_split(&mut self, row: u16) { - let sidebar = self.view.sidebar_rect; - let content_height = sidebar.height; - if content_height < 6 { - return; - } - let relative_y = row.saturating_sub(sidebar.y); - let ratio = (relative_y as f32) / (content_height as f32); - self.sidebar_section_split = ratio.clamp(0.1, 0.9); - self.mark_session_dirty(); - } - - pub(super) fn workspace_at_row(&self, row: u16) -> Option { - let footer = self.sidebar_footer_rect(); - if footer == Rect::default() { - return None; - } - - let cards = if self.view.workspace_card_areas.is_empty() { - crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) - } else { - self.view.workspace_card_areas.clone() - }; - - cards.iter().find_map(|card| { - (row >= card.rect.y && row < card.rect.y + card.rect.height).then_some(card.ws_idx) - }) - } - - pub(super) fn collapsed_workspace_at_row(&self, row: u16) -> Option { - if !self.sidebar_collapsed { - return None; - } - - let (ws_area, _, _) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect); - if ws_area == Rect::default() || row < ws_area.y || row >= ws_area.y + ws_area.height { - return None; - } - - let idx = (row - ws_area.y) as usize; - (idx < self.workspaces.len()).then_some(idx) - } - - pub(super) fn collapsed_agent_detail_target_at( - &self, - row: u16, - ) -> Option<(usize, usize, crate::layout::PaneId)> { - if !self.sidebar_collapsed { - return None; - } - - let (_, _, detail_area) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect); - let detail_content_area = Rect::new( - detail_area.x, - detail_area.y, - detail_area.width, - detail_area.height.saturating_sub(1), - ); - if detail_content_area == Rect::default() - || row < detail_content_area.y - || row >= detail_content_area.y + detail_content_area.height - { - return None; - } - - let detail_idx = (row - detail_content_area.y) as usize; - let details = crate::ui::agent_panel_entries(self); - let detail = details.get(detail_idx)?; - Some((detail.ws_idx, detail.tab_idx, detail.pane_id)) - } - - pub(super) fn workspace_drop_target_at_row( - &self, - row: u16, - ) -> Option { - let area = self.workspace_list_rect(); - let footer = self.sidebar_footer_rect(); - if area == Rect::default() || row < area.y || row >= footer.y { - return None; - } - - let cards = if self.view.workspace_card_areas.is_empty() { - crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) - } else { - self.view.workspace_card_areas.clone() - }; - crate::ui::workspace_drop_slots(self, &cards, area) - .into_iter() - .enumerate() - .min_by_key(|(slot_idx, (_, slot_row))| (row.abs_diff(*slot_row), *slot_idx)) - .map(|(_, (target, _))| target) - } - - pub(super) fn workspace_move_block_params( - &self, - source_ws_idx: usize, - drop_target: crate::app::state::WorkspaceDropTarget, - ) -> Option { - let source = self.workspaces.get(source_ws_idx)?; - if source - .worktree_space() - .is_some_and(|space| space.is_linked_worktree) - { - return None; - } - - let roots = crate::ui::workspace_list_entries_expanded(self) - .into_iter() - .filter_map(|entry| match entry { - crate::ui::WorkspaceListEntry::Workspace { - ws_idx, - indented: false, - } => Some(ws_idx), - crate::ui::WorkspaceListEntry::Workspace { .. } => None, - }) - .collect::>(); - let source_pos = roots.iter().position(|ws_idx| *ws_idx == source_ws_idx)?; - let remaining_roots = roots - .iter() - .copied() - .filter(|ws_idx| *ws_idx != source_ws_idx) - .collect::>(); - let insert_pos = match drop_target { - crate::app::state::WorkspaceDropTarget::Before(target_ws_idx) => remaining_roots - .iter() - .position(|ws_idx| *ws_idx == target_ws_idx)?, - crate::app::state::WorkspaceDropTarget::End => remaining_roots.len(), - }; - if insert_pos == source_pos { - return None; - } - - let workspace_ids = match source.worktree_space() { - Some(source_space) => { - let mut ids = vec![source.id.clone()]; - ids.extend( - self.workspaces - .iter() - .filter(|workspace| workspace.id != source.id) - .filter(|workspace| { - workspace - .worktree_space() - .is_some_and(|space| space.key == source_space.key) - }) - .map(|workspace| workspace.id.clone()), - ); - ids - } - None => vec![source.id.clone()], - }; - let before_workspace_id = match drop_target { - crate::app::state::WorkspaceDropTarget::Before(target_ws_idx) => { - let target = self.workspaces.get(target_ws_idx)?; - let anchor = match crate::ui::workspace_parent_group_state(self, target_ws_idx) - .and_then(|_| target.worktree_space()) - { - Some(target_space) => self - .workspaces - .iter() - .find(|workspace| { - workspace - .worktree_space() - .is_some_and(|space| space.key == target_space.key) - }) - .unwrap_or(target), - None => target, - }; - Some(anchor.id.clone()) - } - crate::app::state::WorkspaceDropTarget::End => None, - }; - - Some(crate::api::schema::WorkspaceMoveBlockParams { - workspace_ids, - before_workspace_id, - }) - } - - pub(super) fn on_agent_panel_sort_toggle(&self, col: u16, row: u16) -> bool { - if self.sidebar_collapsed || self.agent_view_override.is_some() { - return false; - } - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - self.view.sidebar_rect, - self.sidebar_section_split, - ); - let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_sort); - rect.width > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height - } - - pub(super) fn agent_detail_target_at( - &self, - row: u16, - ) -> Option<(usize, usize, crate::layout::PaneId)> { - if self.sidebar_collapsed { - return None; - } - - let detail_area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area); - let body = crate::ui::agent_panel_body_rect( - detail_area, - crate::ui::should_show_scrollbar(metrics), - ); - if body.height == 0 || row < body.y || row >= body.y + body.height { - return None; - } - - let mut row_y = body.y; - let body_bottom = body.y + body.height; - let entries = crate::ui::agent_panel_entries(self); - let scroll = self.agent_panel_scroll.min(metrics.max_offset_from_bottom); - for (index, detail) in entries.iter().enumerate().skip(scroll) { - let height = crate::ui::agent_entry_height_in_body(self, detail, body.height); - if row_y.saturating_add(height) > body_bottom { - break; - } - if row >= row_y && row < row_y.saturating_add(height) { - return Some((detail.ws_idx, detail.tab_idx, detail.pane_id)); - } - row_y = row_y - .saturating_add(height) - .saturating_add(crate::ui::agent_entry_gap(self, index, entries.len())) - .min(body_bottom); - } - None - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use crossterm::event::{MouseButton, MouseEventKind}; - use ratatui::layout::Rect; - - use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path}; - use crate::{ - app::state::{AgentPanelSort, DragTarget, Mode}, - config::SidebarCollapsedModeConfig, - detect::{Agent, AgentState}, - workspace::Workspace, - }; - - #[test] - fn clicking_launcher_opens_global_menu() { - let mut app = app_for_mouse_test(); - let rect = app.state.global_launcher_rect(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - rect.x + rect.width.saturating_sub(1), - rect.y, - )); - - assert_eq!(app.state.mode, Mode::GlobalMenu); - } - - #[test] - fn hovering_global_menu_updates_highlight() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); - - assert_eq!(app.state.global_menu.highlighted, 1); - } - - #[test] - fn clicking_keybinds_menu_item_opens_help() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 2, - )); - - assert_eq!(app.state.mode, Mode::KeybindHelp); - } - - #[test] - fn clicking_settings_menu_item_opens_settings() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 1, - )); - - assert_eq!(app.state.mode, Mode::Settings); - } - - #[test] - fn clicking_reload_config_menu_item_requests_reload() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 3, - )); - - assert!(app.state.request_reload_config); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn update_pending_menu_surfaces_update_ready_entry() { - let mut app = app_for_mouse_test(); - app.state.update_available = Some("0.3.2".into()); - app.state.latest_release_notes_available = true; - - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - assert_eq!( - app.state.global_menu_labels(), - vec![ - "settings", - "keybinds", - "reload config", - "update ready", - "detach" - ] - ); - assert!(!app.state.should_quit); - } - - #[test] - fn menu_surfaces_detach_action() { - let mut app = app_for_mouse_test(); - - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - assert_eq!( - app.state.global_menu_labels(), - vec!["settings", "keybinds", "reload config", "detach"] - ); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 4, - )); - - assert!(app.state.detach_requested); - assert!(!app.state.should_quit); - assert_ne!(app.state.mode, Mode::GlobalMenu); - } - - #[test] - fn whats_new_remains_in_menu_for_latest_installed_release_notes() { - let mut app = app_for_mouse_test(); - app.state.latest_release_notes_available = true; - - assert_eq!( - app.state.global_menu_labels(), - vec![ - "settings", - "keybinds", - "reload config", - "what's new", - "detach" - ] - ); - } - - #[test] - fn clicking_agent_detail_row_switches_to_correct_tab_and_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("main".into()); - let first_pane = ws.tabs[0].root_pane; - let first_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[first_tab].root_pane; - app.state.workspaces = vec![ws]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_terminal_id = app.state.workspaces[0].tabs[first_tab].panes[&second_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 16)); - - assert_eq!(app.state.workspaces[0].active_tab, 1); - assert_eq!( - app.state.workspaces[0].tabs[1].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.workspaces[0].active_tab, first_tab); - assert_eq!( - snapshot.workspaces[0].tabs[first_tab].focused, - Some(second_pane.raw()) - ); - } - - #[test] - fn per_agent_row_heights_preserve_card_gaps_and_trailing_mouse_targets() { - let mut app = app_for_mouse_test(); - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - let second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - for (ws_idx, pane_id, agent) in - [(0, first_pane, Agent::Pi), (1, second_pane, Agent::Claude)] - { - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(agent); - } - app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; - app.state.sidebar_agents.rows_by_agent.insert( - "claude".into(), - vec![ - vec![crate::config::AgentSidebarToken::Agent], - vec![crate::config::AgentSidebarToken::Workspace], - ], - ); - app.state.sidebar_agents.row_gap = 1; - let detail_area = app.state.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(&app.state, detail_area); - let body = crate::ui::agent_panel_body_rect( - detail_area, - crate::ui::should_show_scrollbar(metrics), - ); - - assert_eq!( - app.state.agent_detail_target_at(body.y), - Some((0, 0, first_pane)) - ); - assert_eq!(app.state.agent_detail_target_at(body.y + 1), None); - assert_eq!( - app.state.agent_detail_target_at(body.y + 3), - Some((1, 0, second_pane)) - ); - - app.state.sidebar_agents.row_gap = 0; - assert_eq!( - app.state.agent_detail_target_at(body.y + 1), - Some((1, 0, second_pane)) - ); - } - - #[test] - fn agent_hit_testing_clamps_scroll_after_dynamic_filter_shrink() { - let mut app = app_for_mouse_test(); - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - let second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - for (ws_idx, pane_id) in [(0, first_pane), (1, second_pane)] { - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - } - app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams { - source: "example.views".to_string(), - label: None, - filter: Some(crate::api::schema::AgentViewFilter::Eq { - field: crate::api::schema::AgentViewField::Builtin( - crate::api::schema::AgentViewBuiltinField::WorkspaceId, - ), - value: crate::api::schema::AgentViewValue::Context { - context: crate::api::schema::AgentViewContext::CurrentWorkspaceId, - }, - }), - sort: Vec::new(), - }); - app.state.agent_panel_scroll = 10; - let detail_area = app.state.agent_panel_rect(); - let body = crate::ui::agent_panel_body_rect(detail_area, false); - - assert_eq!( - app.state.agent_detail_target_at(body.y), - Some((0, 0, first_pane)) - ); - } - - #[test] - fn clicking_agent_panel_toggle_switches_sort() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.agent_panel_scroll = 3; - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - app.state.view.sidebar_rect, - app.state.sidebar_section_split, - ); - let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_sort); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - toggle.x, - toggle.y, - )); - - assert_eq!(app.state.agent_panel_sort, AgentPanelSort::Priority); - assert_eq!(app.state.agent_panel_scroll, 0); - } - - #[test] - fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() { - let mut app = app_for_mouse_test(); - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - - let second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - app.state.view.sidebar_rect, - app.state.sidebar_section_split, - ); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - detail_area.x + 2, - detail_area.y + 6, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert_eq!(app.state.workspaces[1].active_tab, 0); - assert_eq!( - app.state.workspaces[1].tabs[0].layout.focused(), - second_pane - ); - } - - #[test] - fn scrolling_agent_panel_with_wheel_updates_agent_panel_scroll() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - - let mut tabs = Vec::new(); - for (tab_name, agent) in [ - ("logs", Agent::Claude), - ("review", Agent::Codex), - ("ops", Agent::Gemini), - ] { - let tab_idx = ws.test_add_tab(Some(tab_name)); - let pane_id = ws.tabs[tab_idx].root_pane; - tabs.push((tab_idx, pane_id, agent)); - } - - app.state.workspaces = vec![ws]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - for (tab_idx, pane_id, agent) in tabs { - let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(agent); - } - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let detail_area = app.state.agent_panel_rect(); - assert!(crate::ui::should_show_scrollbar( - crate::ui::agent_panel_scroll_metrics(&app.state, detail_area) - )); - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - detail_area.x + 1, - detail_area.y + 4, - )); - - assert_eq!(app.state.agent_panel_scroll, 1); - assert_eq!(app.state.selected, 0); - } - - #[test] - fn clicking_scrolled_agent_detail_row_switches_to_correct_tab_and_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[second_tab].root_pane; - let mut extra_tabs = Vec::new(); - for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] { - let tab_idx = ws.test_add_tab(Some(tab_name)); - let pane_id = ws.tabs[tab_idx].root_pane; - extra_tabs.push((tab_idx, pane_id, agent)); - } - - app.state.workspaces = vec![ws]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - for (tab_idx, pane_id, agent) in extra_tabs { - let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(agent); - } - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; - app.state.sidebar_agents.rows_by_agent.insert( - "claude".into(), - vec![ - vec![crate::config::AgentSidebarToken::Agent], - vec![crate::config::AgentSidebarToken::Workspace], - ], - ); - app.state.agent_panel_scroll = 1; - - let detail_area = app.state.agent_panel_rect(); - let body = crate::ui::agent_panel_body_rect(detail_area, true); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - body.x + 1, - body.y + 1, - )); - - assert_eq!(app.state.workspaces[0].active_tab, second_tab); - assert_eq!( - app.state.workspaces[0].tabs[second_tab].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn clicking_collapsed_agent_row_switches_to_correct_tab_and_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[second_tab].root_pane; - app.state.workspaces = vec![ws]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.sidebar_collapsed = true; - app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); - app.state.view.terminal_area = Rect::new(4, 0, 80, 20); - - let (_, _, detail_area) = - crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - detail_area.x, - detail_area.y + 1, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 1); - assert_eq!( - app.state.workspaces[0].tabs[1].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn clicking_collapsed_priority_agent_row_switches_to_matching_workspace() { - let mut app = app_for_mouse_test(); - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - let second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.sidebar_collapsed = true; - app.state.agent_panel_sort = AgentPanelSort::Priority; - app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); - app.state.view.terminal_area = Rect::new(4, 0, 80, 20); - - let set_state = |app: &mut crate::app::App, ws_idx: usize, pane_id, state| { - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(Agent::Claude); - terminal.state = state; - }; - set_state(&mut app, 0, first_pane, AgentState::Working); - set_state(&mut app, 1, second_pane, AgentState::Blocked); - - let (_, _, detail_area) = - crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - detail_area.x, - detail_area.y, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert_eq!( - app.state.workspaces[1].tabs[0].layout.focused(), - second_pane - ); - } - - #[test] - fn clicking_collapsed_sidebar_toggle_expands_sidebar() { - let mut app = app_for_mouse_test(); - app.state.sidebar_collapsed = true; - app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); - app.state.view.terminal_area = Rect::new(4, 0, 80, 20); - - let toggle = crate::ui::collapsed_sidebar_toggle_rect(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - toggle.x, - toggle.y, - )); - - assert!(!app.state.sidebar_collapsed); - } - - #[test] - fn hidden_collapsed_sidebar_has_no_mouse_expand_hotspot() { - let mut app = app_for_mouse_test(); - app.state.sidebar_collapsed = true; - app.state.sidebar_collapsed_mode = SidebarCollapsedModeConfig::Hidden; - app.state.view.sidebar_rect = Rect::new(0, 0, 0, 20); - app.state.view.terminal_area = Rect::new(0, 0, 80, 20); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 19)); - - assert!(app.state.sidebar_collapsed); - } - - #[test] - fn clicking_expanded_sidebar_toggle_collapses_sidebar() { - let mut app = app_for_mouse_test(); - app.state.sidebar_collapsed = false; - app.state.view.sidebar_rect = Rect::new(0, 0, 26, 20); - app.state.view.terminal_area = Rect::new(26, 0, 80, 20); - - let toggle = crate::ui::expanded_sidebar_toggle_rect(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - toggle.x, - toggle.y, - )); - - assert!(app.state.sidebar_collapsed); - assert!(app.state.drag.is_none()); - } - - #[test] - fn clicking_workspace_switches_on_mouse_up() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let target_row = app.state.view.workspace_card_areas[1].rect.y; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - 2, - target_row, - )); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspace_presses.len(), 1); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert!(app.state.workspace_presses.is_empty()); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.active, Some(1)); - assert_eq!(snapshot.selected, 1); - } - - #[test] - fn clicking_worktree_parent_row_focuses_workspace_without_toggling() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("main"), Workspace::test_new("issue")]; - for (idx, checkout_path) in ["/repo/herdr", "/repo/herdr-issue"].into_iter().enumerate() { - app.state.workspaces[idx].worktree_space = - Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout_path.into(), - is_linked_worktree: idx > 0, - }); - } - app.state.active = None; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let parent = app.state.view.workspace_card_areas[0].rect; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - parent.x + 2, - parent.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - parent.x + 2, - parent.y, - )); - - assert_eq!(app.state.active, Some(0)); - assert!(!app.state.collapsed_space_keys.contains("repo-key")); - } - - #[test] - fn clicking_worktree_parent_chevron_toggles_group_only() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("main"), Workspace::test_new("issue")]; - for (idx, checkout_path) in ["/repo/herdr", "/repo/herdr-issue"].into_iter().enumerate() { - app.state.workspaces[idx].worktree_space = - Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout_path.into(), - is_linked_worktree: idx > 0, - }); - } - app.state.active = None; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let parent = app.state.view.workspace_card_areas[0]; - let chevron = crate::ui::workspace_group_chevron_rect(&parent); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - chevron.x, - chevron.y, - )); - - assert_eq!(app.state.active, None); - assert!(app.state.workspace_presses.is_empty()); - assert!(app.state.collapsed_space_keys.contains("repo-key")); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - chevron.x, - chevron.y, - )); - - assert!(!app.state.collapsed_space_keys.contains("repo-key")); - } - - #[test] - fn wheel_workspace_selection_follows_grouped_visual_order_without_scrollbar() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("main"), - Workspace::test_new("normal"), - Workspace::test_new("issue"), - ]; - for (idx, checkout_path) in [(0, "/repo/herdr"), (2, "/repo/herdr-issue")] { - app.state.workspaces[idx].worktree_space = - Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout_path.into(), - is_linked_worktree: idx != 0, - }); - } - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 30)); - let list = app.state.workspace_list_rect(); - assert!(!crate::ui::should_show_scrollbar( - crate::ui::workspace_list_scroll_metrics(&app.state, list) - )); - - app.handle_mouse(mouse(MouseEventKind::ScrollDown, list.x + 1, list.y + 1)); - - assert_eq!(app.state.selected, 2); - } - - #[test] - fn dragging_workspace_reorders_without_changing_identity() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("a"), - Workspace::test_new("b"), - Workspace::test_new("c"), - ]; - app.state.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; - app.state.sidebar_spaces.row_gap = 0; - let active_id = app.state.workspaces[1].id.clone(); - let selected_id = app.state.workspaces[2].id.clone(); - app.state.active = Some(1); - app.state.selected = 2; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let packed_boundary_row = app.state.view.workspace_card_areas[1].rect.y; - assert_eq!( - app.state.workspace_drop_target_at_row(packed_boundary_row), - Some(crate::app::state::WorkspaceDropTarget::Before(2)) - ); - - let source_row = app.state.view.workspace_card_areas[1].rect.y; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state, - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::Before(0), - ) - .unwrap(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - 2, - source_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::WorkspaceReorder { - source_ws_idx: 1, - drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(0)), - .. - }) - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - let names: Vec<_> = app - .state - .workspaces - .iter() - .map(|ws| ws.display_name()) - .collect(); - assert_eq!(names, vec!["b", "a", "c"]); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.selected, 2); - assert_eq!(app.state.workspaces[0].id, active_id); - assert_eq!(app.state.workspaces[2].id, selected_id); - let events = app.event_hub.events_after(0); - assert!(events.iter().any(|(_, event)| matches!( - event.data, - crate::api::schema::EventData::WorkspaceMoved { .. } - ))); - assert!(!events.iter().any(|(_, event)| matches!( - event.data, - crate::api::schema::EventData::WorkspaceReordered { .. } - ))); - let snapshot = capture_snapshot(&app.state); - let captured_names: Vec<_> = snapshot - .workspaces - .iter() - .map(|ws| ws.custom_name.clone().unwrap()) - .collect(); - assert_eq!(captured_names, vec!["b", "a", "c"]); - } - - #[test] - fn clicking_tab_scroll_button_reveals_hidden_tabs_without_renaming() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("logs")); - ws.test_add_tab(Some("review")); - ws.test_add_tab(Some("ops")); - ws.test_add_tab(Some("notes")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20)); - - let right = app.state.view.tab_scroll_right_hit_area; - assert!(right.width > 0); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - right.x + 1, - right.y, - )); - - assert_eq!(app.state.tab_scroll, 1); - assert!(!app.state.tab_scroll_follow_active); - assert_eq!(app.state.workspaces[0].active_tab, 0); - assert_eq!(app.state.view.tab_hit_areas[0].width, 0); - assert!(app.state.workspaces[0].tabs[0].custom_name.is_none()); - assert_eq!( - app.state.workspaces[0].tabs[1].custom_name.as_deref(), - Some("logs") - ); - } - - #[test] - fn clicking_last_visible_tab_at_right_edge_does_not_overscroll() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - for name in [ - "one", "two", "three", "four", "five", "six", "seven", "eight", - ] { - ws.test_add_tab(Some(name)); - } - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.tab_scroll = usize::MAX; - app.state.tab_scroll_follow_active = false; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20)); - - let last_idx = app.state.workspaces[0].tabs.len() - 1; - let target = app.state.view.tab_hit_areas[last_idx]; - let clamped_scroll = app.state.tab_scroll; - assert!(target.width > 0, "last tab should already be visible"); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - target.x + 1, - target.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - target.x + 1, - target.y, - )); - - assert_eq!(app.state.workspaces[0].active_tab, last_idx); - assert_eq!(app.state.tab_scroll, clamped_scroll); - assert!(app.state.view.tab_hit_areas[last_idx].width > 0); - } - - #[test] - fn dragging_tab_reorders_auto_and_custom_names_without_materializing_numbers() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("foo")); - ws.test_add_tab(None); - let moved_root = ws.tabs[0].root_pane; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let source = app.state.view.tab_hit_areas[0]; - let last = app.state.view.tab_hit_areas[2]; - let drop_col = last.x + last.width; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - source.x + 1, - source.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - drop_col, - source.y, - )); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::TabReorder { - ws_idx: 0, - source_tab_idx: 0, - insert_idx: Some(3), - .. - }) - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - drop_col, - source.y, - )); - - let labels: Vec<_> = app.state.workspaces[0] - .tabs - .iter() - .enumerate() - .map(|(tab_idx, _)| app.state.workspaces[0].tab_display_name(tab_idx).unwrap()) - .collect(); - assert_eq!(labels, vec!["foo", "2", "3"]); - assert_eq!( - app.state.workspaces[0].tabs[0].custom_name.as_deref(), - Some("foo") - ); - assert!(app.state.workspaces[0].tabs[1].custom_name.is_none()); - assert!(app.state.workspaces[0].tabs[2].custom_name.is_none()); - assert_eq!(app.state.workspaces[0].tabs[0].number, 2); - assert_eq!(app.state.workspaces[0].tabs[1].number, 3); - assert_eq!(app.state.workspaces[0].tabs[2].number, 1); - assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root); - assert_eq!(app.state.workspaces[0].active_tab, 2); - } - - fn temp_git_repo(branch: &str) -> std::path::PathBuf { - let repo = unique_temp_path("sidebar-drop-slot-repo"); - fs::create_dir_all(repo.join(".git")).unwrap(); - fs::write( - repo.join(".git/HEAD"), - format!("ref: refs/heads/{branch}\n"), - ) - .unwrap(); - repo - } - - fn workspace_with_space(name: &str, key: &str) -> Workspace { - let mut ws = Workspace::test_new(name); - ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/{name}").into(), - is_linked_worktree: name != "main", - }); - ws - } - - #[test] - fn top_drop_slot_is_distinct_from_gap_below_first_workspace() { - let mut app = app_for_mouse_test(); - let first_repo = temp_git_repo("main"); - let second_repo = temp_git_repo("main"); - - let mut first = Workspace::test_new("a"); - let first_root = first.tabs[0].root_pane; - first.identity_cwd = first_repo.clone(); - first.refresh_git_ahead_behind(); - - let mut second = Workspace::test_new("b"); - let second_root = second.tabs[0].root_pane; - second.identity_cwd = second_repo.clone(); - second.refresh_git_ahead_behind(); - - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_root] - .attached_terminal_id - .clone(); - app.state.terminals.get_mut(&first_terminal_id).unwrap().cwd = first_repo.clone(); - let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_root] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .cwd = second_repo.clone(); - app.state.sidebar_spaces.row_gap = 1; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - assert_eq!( - app.state.workspace_drop_target_at_row(0), - Some(crate::app::state::WorkspaceDropTarget::Before(0)) - ); - assert_eq!( - app.state.workspace_drop_target_at_row(1), - Some(crate::app::state::WorkspaceDropTarget::Before(0)) - ); - assert_eq!( - app.state.workspace_drop_target_at_row(2), - Some(crate::app::state::WorkspaceDropTarget::Before(0)) - ); - assert_eq!( - app.state.workspace_drop_target_at_row(3), - Some(crate::app::state::WorkspaceDropTarget::Before(1)) - ); - - let _ = fs::remove_dir_all(first_repo); - let _ = fs::remove_dir_all(second_repo); - } - - #[test] - fn bottom_drop_slot_stays_below_last_workspace_not_footer() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("a"), - Workspace::test_new("b"), - Workspace::test_new("c"), - ]; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 24)); - - let cards = &app.state.view.workspace_card_areas; - let bottom_slot = crate::ui::workspace_drop_indicator_row( - &app.state, - cards, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::End, - ) - .unwrap(); - - let last = cards.last().unwrap().rect; - assert_eq!(bottom_slot, last.y + last.height); - assert!(bottom_slot < app.state.sidebar_footer_rect().y.saturating_sub(1)); - } - - #[test] - fn grouped_sidebar_drop_slots_do_not_land_inside_compact_group() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("main", "repo-key"), - Workspace::test_new("normal"), - workspace_with_space("issue", "repo-key"), - ]; - app.state.active = Some(1); - app.state.selected = 1; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); - - let cards = &app.state.view.workspace_card_areas; - let order = cards.iter().map(|card| card.ws_idx).collect::>(); - assert_eq!(order, vec![0, 2, 1]); - let issue = cards.iter().find(|card| card.ws_idx == 2).unwrap(); - let normal = cards.iter().find(|card| card.ws_idx == 1).unwrap(); - - assert_eq!( - app.state.workspace_drop_target_at_row(issue.rect.y), - Some(crate::app::state::WorkspaceDropTarget::Before(1)) - ); - assert_eq!( - crate::ui::workspace_drop_indicator_row( - &app.state, - cards, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::End, - ), - Some(normal.rect.y + normal.rect.height) - ); - } - - #[test] - fn plain_drag_anchors_to_the_selected_parentless_linked_workspace() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("one", "repo-key"), - workspace_with_space("two", "repo-key"), - Workspace::test_new("normal"), - ]; - let target_id = app.state.workspaces[1].id.clone(); - - let params = app - .state - .workspace_move_block_params(2, crate::app::state::WorkspaceDropTarget::Before(1)) - .unwrap(); - - assert_eq!(params.workspace_ids, [app.state.workspaces[2].id.clone()]); - assert_eq!( - params.before_workspace_id.as_deref(), - Some(target_id.as_str()) - ); - } - - #[test] - fn dragging_worktree_parent_reorders_the_complete_group() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("main", "repo-key"), - Workspace::test_new("normal"), - workspace_with_space("issue", "repo-key"), - ]; - app.state.active = Some(2); - app.state.selected = 1; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); - - let parent = app - .state - .view - .workspace_card_areas - .iter() - .find(|card| card.ws_idx == 0) - .unwrap() - .rect; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state, - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::End, - ) - .unwrap(); - let active_id = app.state.workspaces[2].id.clone(); - let selected_id = app.state.workspaces[1].id.clone(); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, parent.y)); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::WorkspaceReorder { - source_ws_idx: 0, - drop_target: Some(crate::app::state::WorkspaceDropTarget::End), - .. - }) - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - assert_eq!( - app.state - .workspaces - .iter() - .map(|workspace| workspace.display_name()) - .collect::>(), - ["normal", "main", "issue"] - ); - assert_eq!( - app.state.workspaces[app.state.active.unwrap()].id, - active_id - ); - assert_eq!(app.state.workspaces[app.state.selected].id, selected_id); - } - - #[test] - fn dragging_collapsed_worktree_parent_still_moves_hidden_children() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("issue", "repo-key"), - Workspace::test_new("normal"), - workspace_with_space("main", "repo-key"), - workspace_with_space("review", "repo-key"), - ]; - app.state.active = Some(0); - app.state.selected = 1; - app.state.collapsed_space_keys.insert("repo-key".into()); - let active_id = app.state.workspaces[0].id.clone(); - let selected_id = app.state.workspaces[1].id.clone(); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); - assert_eq!(app.state.view.workspace_card_areas.len(), 3); - - let parent = app.state.view.workspace_card_areas[0].rect; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state, - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::End, - ) - .unwrap(); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, parent.y)); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - assert_eq!( - app.state - .workspaces - .iter() - .map(|workspace| workspace.display_name()) - .collect::>(), - ["normal", "main", "issue", "review"] - ); - assert_eq!( - app.state.workspaces[app.state.active.unwrap()].id, - active_id - ); - assert_eq!(app.state.workspaces[app.state.selected].id, selected_id); - } - - #[test] - fn dragging_worktree_space_member_does_not_reorder_workspaces() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("main", "repo-key"), - Workspace::test_new("normal"), - workspace_with_space("issue", "repo-key"), - ]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); - - let source = app - .state - .view - .workspace_card_areas - .iter() - .find(|card| card.ws_idx == 2) - .unwrap() - .rect; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state, - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::Before(0), - ) - .unwrap(); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, source.y)); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - assert!(app.state.drag.is_none()); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - let names = app - .state - .workspaces - .iter() - .map(|ws| ws.display_name()) - .collect::>(); - assert_eq!(names, vec!["main", "normal", "issue"]); - } - - #[test] - fn dragging_sidebar_divider_sets_manual_width() { - let mut app = app_for_mouse_test(); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5)); - - assert_eq!(app.state.sidebar_width, 31); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.sidebar_width, Some(31)); - } - - #[test] - fn dragging_sidebar_bottom_divider_still_sets_manual_width() { - let mut app = app_for_mouse_test(); - let divider_col = app.state.view.sidebar_rect.x + app.state.view.sidebar_rect.width - 1; - let bottom_row = app.state.view.sidebar_rect.y + app.state.view.sidebar_rect.height - 1; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - divider_col, - bottom_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - divider_col + 5, - bottom_row, - )); - - assert_eq!(app.state.sidebar_width, 31); - } - - #[test] - fn dragging_past_max_clamps_to_configured_max() { - let mut app = app_for_mouse_test(); - app.state.sidebar_max_width = 30; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 50, 5)); - - assert_eq!(app.state.sidebar_width, 30); - } - - #[test] - fn dragging_below_min_clamps_to_configured_min() { - let mut app = app_for_mouse_test(); - app.state.sidebar_min_width = 22; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 5, 5)); - - assert_eq!(app.state.sidebar_width, 22); - } - - #[test] - fn dragging_sidebar_section_divider_sets_split_ratio() { - let mut app = app_for_mouse_test(); - let divider = crate::ui::sidebar_section_divider_rect( - app.state.view.sidebar_rect, - app.state.sidebar_section_split, - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - divider.x + 1, - divider.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - divider.x + 1, - divider.y + 4, - )); - - assert!(app.state.sidebar_section_split > 0.5); - let snapshot = capture_snapshot(&app.state); - assert_eq!( - snapshot.sidebar_section_split, - Some(app.state.sidebar_section_split) - ); - } - - #[test] - fn double_clicking_sidebar_divider_resets_default_width() { - let mut app = app_for_mouse_test(); - app.state.default_sidebar_width = 26; - app.state.sidebar_width = 30; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - - assert_eq!(app.state.sidebar_width, 26); - assert!(app.state.drag.is_none()); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.sidebar_width, Some(26)); - } -} diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs deleted file mode 100644 index 09d838cc..00000000 --- a/src/app/input/terminal.rs +++ /dev/null @@ -1,2087 +0,0 @@ -use bytes::Bytes; -use crossterm::event::KeyCode; -use tracing::{debug, warn}; - -use crate::{ - app::{App, InputSourceId, Mode, TerminalInputTarget}, - input::TerminalKey, -}; - -struct PreparedPaneInput { - ws_idx: usize, - pane_id: crate::layout::PaneId, - target: TerminalInputTarget, - bytes: Bytes, -} - -enum PreparedPopupInput { - NotOpen, - Consumed, - Bytes { - target: TerminalInputTarget, - bytes: Bytes, - }, -} - -fn is_modifier_only_key(code: &KeyCode) -> bool { - matches!(code, KeyCode::Modifier(_)) -} - -impl App { - #[cfg(test)] - pub(crate) fn handle_terminal_key_headless( - &mut self, - key: TerminalKey, - ) -> Option { - self.handle_terminal_key_headless_from(crate::app::LOCAL_INPUT_SOURCE, key) - } - - pub(crate) fn handle_terminal_key_headless_from( - &mut self, - source_id: InputSourceId, - key: TerminalKey, - ) -> Option { - match self.prepare_popup_key_forward(key.clone()) { - PreparedPopupInput::NotOpen => {} - PreparedPopupInput::Consumed => return None, - PreparedPopupInput::Bytes { target, bytes } => { - let Some(runtime) = self.popup_runtime() else { - self.close_popup_pane(); - return None; - }; - return runtime.try_send_bytes(bytes).is_ok().then_some(target); - } - } - - let input = self.prepare_terminal_key_forward(source_id, key)?; - let sent = self - .lookup_runtime_sender(input.ws_idx, input.pane_id) - .is_some_and(|runtime| runtime.try_send_bytes(input.bytes).is_ok()); - sent.then_some(input.target) - } - - fn prepare_terminal_key_forward( - &mut self, - source_id: InputSourceId, - key: TerminalKey, - ) -> Option { - let key_event = key.as_key_event(); - if self.try_copy_retained_selection(source_id, key.clone()) { - return None; - } - - self.state.clear_selection(); - self.selection_autoscroll_deadline = None; - self.state.update_dismissed = true; - - if let Some(action) = - super::terminal_direct_non_indexed_navigation_action(&self.state, &key) - { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - action = ?action, - "intercepted terminal direct keybinding before forwarding to pane" - ); - if action == crate::input::KeybindAction::EditScrollback { - self.launch_focused_scrollback_editor(); - } else { - self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct); - } - return None; - } - - if let Some(binding) = super::navigate::command_for_key( - &self.state, - &key, - crate::input::KeybindDispatch::Direct, - ) { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - command = %binding.label, - "intercepted terminal direct custom command before forwarding to pane" - ); - self.launch_custom_command(binding, super::navigate::ActionContext::Direct); - return None; - } - - if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, &key) { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - action = ?action, - "intercepted terminal direct indexed keybinding before forwarding to pane" - ); - self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct); - return None; - } - - if self.state.is_prefix_key(&key) { - self.state.mode = Mode::Prefix; - return None; - } - - if is_modifier_only_key(&key_event.code) { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - "dropping modifier-only terminal key event instead of forwarding it to pane" - ); - return None; - } - - let ws_idx = self.state.active?; - let ws = self.state.workspaces.get(ws_idx)?; - let pane_id = ws.focused_pane_id()?; - let terminal_id = ws.terminal_id(pane_id)?.clone(); - let rt = - self.state - .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?; - - // Intercept plain PageUp/PageDown presses for pane scrollback only - // when the focused pane looks like a shell transcript. Normal-screen - // pagers such as `less -X` keep the primary screen but enter - // application cursor mode while they own special keys. - // Modified page keys are pane shortcuts, and release events should not - // produce a second host-scroll action. - // Only intercept when we know the pane state; if input_state is unknown, - // fail-open and forward the key to the pane. - if matches!(key_event.code, KeyCode::PageUp | KeyCode::PageDown) - && key_event.modifiers.is_empty() - { - if let Some(host_scroll) = rt.plain_page_keys_use_host_scrollback() { - if host_scroll { - if key_event.kind == crossterm::event::KeyEventKind::Release { - return None; - } - if matches!( - key_event.kind, - crossterm::event::KeyEventKind::Press - | crossterm::event::KeyEventKind::Repeat - ) { - let lines = self - .state - .pane_info_by_id(pane_id) - .map(|info| info.inner_rect.height as usize) - .unwrap_or(10) - .max(1); - if key_event.code == KeyCode::PageUp { - self.state - .scroll_pane_up(&self.terminal_runtimes, pane_id, lines); - } else { - self.state - .scroll_pane_down(&self.terminal_runtimes, pane_id, lines); - } - debug!( - code = ?key_event.code, - lines, - "intercepted page key for pane scrollback" - ); - return None; - } - } - } - } - - rt.scroll_reset(); - let protocol = rt.keyboard_protocol(); - let bytes = rt.encode_terminal_key(key.clone()); - - if matches!(key_event.code, KeyCode::Esc) - || key_event - .modifiers - .contains(crossterm::event::KeyModifiers::ALT) - { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - protocol = ?protocol, - encoded = ?bytes, - "forwarding potentially-ambiguous terminal key to pane" - ); - } - - if bytes.is_empty() { - if key.kind != crossterm::event::KeyEventKind::Release - && !matches!( - key.code, - KeyCode::CapsLock - | KeyCode::ScrollLock - | KeyCode::NumLock - | KeyCode::PrintScreen - | KeyCode::Pause - | KeyCode::Menu - | KeyCode::KeypadBegin - | KeyCode::Media(_) - | KeyCode::Modifier(_) - ) - { - warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding"); - } - return None; - } - - Some(PreparedPaneInput { - ws_idx, - pane_id, - target: TerminalInputTarget { terminal_id }, - bytes: Bytes::from(bytes), - }) - } - - fn prepare_popup_key_forward(&mut self, key: TerminalKey) -> PreparedPopupInput { - if self.state.popup_pane.is_none() { - return PreparedPopupInput::NotOpen; - } - let Some(terminal_id) = self - .state - .popup_pane - .as_ref() - .map(|popup| popup.terminal_id.clone()) - else { - return PreparedPopupInput::NotOpen; - }; - let Some(rt) = self.terminal_runtimes.get(&terminal_id) else { - self.close_popup_pane(); - return PreparedPopupInput::Consumed; - }; - rt.scroll_reset(); - let bytes = rt.encode_terminal_key(key.clone()); - self.state.mode = Mode::Terminal; - if bytes.is_empty() { - PreparedPopupInput::Consumed - } else { - PreparedPopupInput::Bytes { - target: TerminalInputTarget { terminal_id }, - bytes: Bytes::from(bytes), - } - } - } - - pub(crate) fn host_keyboard_report_all_requested(&self) -> bool { - if self.state.popup_pane.is_none() - && matches!(self.state.mode, Mode::Prefix | Mode::Navigate) - { - return true; - } - - let runtime = if self.state.popup_pane.is_some() { - self.popup_runtime() - } else if self.state.mode == Mode::Terminal { - self.state.active.and_then(|ws_idx| { - self.state - .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) - }) - } else { - None - }; - - runtime.is_some_and(crate::terminal::TerminalRuntime::keyboard_report_all_requested) - } - - fn terminal_input_runtime( - &self, - target: &TerminalInputTarget, - ) -> Option<&crate::terminal::TerminalRuntime> { - if let Some(runtime) = self.terminal_runtimes.get(&target.terminal_id) { - return Some(runtime); - } - #[cfg(test)] - for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() { - for tab in &workspace.tabs { - for (&pane_id, pane) in &tab.panes { - if pane.attached_terminal_id == target.terminal_id { - return self.state.runtime_for_pane_in_workspace( - &self.terminal_runtimes, - ws_idx, - pane_id, - ); - } - } - } - } - None - } - - pub(crate) fn forward_terminal_key_to_target_headless( - &self, - target: &TerminalInputTarget, - key: TerminalKey, - ) -> bool { - let Some(runtime) = self.terminal_input_runtime(target) else { - return false; - }; - let bytes = runtime.encode_terminal_key(key.clone()); - bytes.is_empty() || runtime.try_send_bytes(Bytes::from(bytes)).is_ok() - } - - fn take_pressed_keys_for_source( - &mut self, - source_id: crate::app::InputSourceId, - ) -> Vec { - self.input_leases.remove_source(source_id) - } - - pub(crate) fn release_input_target_headless(&mut self, target: &TerminalInputTarget) { - for pressed in self.input_leases.remove_target(target) { - let release = pressed - .key - .with_kind(crossterm::event::KeyEventKind::Release); - let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release); - } - } - - pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) { - // Pending URL clicks survive this call; see clear_input_source. - self.state.clear_chrome_gesture(source_id); - for pressed in self.take_pressed_keys_for_source(source_id) { - let release = pressed - .key - .with_kind(crossterm::event::KeyEventKind::Release); - let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release); - } - } - - #[cfg(test)] - pub(super) async fn handle_terminal_key( - &mut self, - key: TerminalKey, - ) -> Option { - self.handle_terminal_key_headless_from(crate::app::LOCAL_INPUT_SOURCE, key) - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; - use ratatui::layout::Rect; - - #[cfg(target_os = "linux")] - use super::super::wait_for_detached_process_reap; - use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes}; - #[cfg(unix)] - use super::super::{unique_temp_path, wait_for_file}; - use super::*; - use crate::{config::Config, events::AppEvent, workspace::Workspace}; - - #[cfg(unix)] - fn app_with_spawned_workspace() -> App { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.default_shell = "/bin/sh".into(); - let (workspace, terminal, runtime) = Workspace::new( - std::env::current_dir().unwrap_or_else(|_| "/".into()), - 24, - 80, - app.state.pane_scrollback_limit_bytes, - app.state.host_terminal_theme, - app.state.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), - app.event_tx.clone(), - app.render_notify.clone(), - app.render_dirty.clone(), - ) - .expect("workspace should spawn"); - app.state.workspaces = vec![workspace]; - app.terminal_runtimes.insert(terminal.id.clone(), runtime); - app.state.terminals.insert(terminal.id.clone(), terminal); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app - } - - #[cfg(unix)] - fn shutdown_test_runtimes(app: &mut App) { - for (_, runtime) in app.terminal_runtimes.drain() { - runtime.shutdown(); - } - } - - fn app_with_screen_bytes(bytes: &[u8]) -> (App, crate::layout::PaneInfo) { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - info.inner_rect.width, - info.inner_rect.height, - bytes, - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - (app, info) - } - - fn double_click(app: &mut App, col: u16, row: u16) { - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row)); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - } - - fn modified_mouse( - kind: MouseEventKind, - col: u16, - row: u16, - modifiers: KeyModifiers, - ) -> crossterm::event::MouseEvent { - crossterm::event::MouseEvent { - kind, - column: col, - row, - modifiers, - } - } - - fn clipboard_write_content(app: &mut App) -> Vec { - match app.event_rx.try_recv().expect("clipboard write event") { - AppEvent::ClipboardWrite { content } => content, - event => panic!("unexpected event: {event:?}"), - } - } - - fn assert_visible_selection(app: &App) { - assert!(app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_visible)); - } - - #[cfg(unix)] - fn install_test_link_handler(app: &mut App) { - let plugin_root = std::env::temp_dir(); - app.state.installed_plugins = std::collections::HashMap::from([( - "example.links".to_string(), - crate::api::schema::InstalledPluginInfo { - plugin_id: "example.links".into(), - name: "Links".into(), - version: "0.1.0".into(), - min_herdr_version: "0.6.10".into(), - description: None, - manifest_path: plugin_root.join("herdr-plugin.toml").display().to_string(), - plugin_root: plugin_root.display().to_string(), - enabled: true, - platforms: None, - build: Vec::new(), - startup: Vec::new(), - actions: vec![crate::api::schema::PluginManifestAction { - id: "open".into(), - title: "Open link".into(), - description: None, - contexts: Vec::new(), - platforms: None, - command: vec!["sh".into(), "-c".into(), ":".into()], - }], - events: Vec::new(), - panes: Vec::new(), - link_handlers: vec![crate::api::schema::PluginManifestLinkHandler { - id: "github-issue".into(), - title: "Open GitHub issue".into(), - pattern: "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$".into(), - action: "open".into(), - platforms: None, - }], - source: crate::api::schema::PluginSourceInfo::default(), - warnings: Vec::new(), - }, - )]); - } - - #[tokio::test] - async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app - .state - .runtime_for_pane(&app.terminal_runtimes, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - let start_row = info.inner_rect.y; - let start_col = info.inner_rect.x + 2; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - start_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - start_col, - info.inner_rect.y.saturating_sub(1), - )); - - let end_metrics = app - .state - .runtime_for_pane(&app.terminal_runtimes, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after drag"); - assert_eq!( - end_metrics.offset_from_bottom, - start_metrics.offset_from_bottom + 3 - ); - - let selection = app.state.selection.as_ref().expect("selection after drag"); - assert!(selection.is_visible()); - assert_eq!( - selection.ordered_cells(), - ( - ( - (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, - 2, - ), - (start_metrics.max_offset_from_bottom as u32, 2), - ) - ); - } - - #[tokio::test] - async fn releasing_dragged_selection_clears_highlight_after_copy() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let row = info.inner_rect.y; - let start_col = info.inner_rect.x + 1; - let end_col = info.inner_rect.x + 4; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - assert!(app.state.selection.is_some()); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn drag_copy_then_click_does_not_reuse_double_click_candidate() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - let row = info.inner_rect.y; - let start_col = info.inner_rect.x; - let end_col = info.inner_rect.x + 4; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - assert!(app.last_pane_click.is_some()); - - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - assert!(app.last_pane_click.is_none()); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - assert!(app.last_pane_click.is_none()); - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - - assert!(app.last_pane_click.is_some()); - assert!(app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn double_click_selects_and_copies_word() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta-gamma_delta@omega"); - let col = info.inner_rect.x + 13; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - - assert_eq!(clipboard_write_content(&mut app), b"beta-gamma_delta@omega"); - assert_visible_selection(&app); - } - - #[tokio::test] - async fn copy_on_select_disabled_keeps_drag_selection_without_copying() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - app.state.copy_on_select = false; - let row = info.inner_rect.y; - let start_col = info.inner_rect.x; - let end_col = info.inner_rect.x + 4; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - assert_visible_selection(&app); - assert!(!app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_finalized)); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - - assert_visible_selection(&app); - assert_eq!( - app.state - .selection - .as_ref() - .map(crate::selection::Selection::ordered_cells), - Some(((0, 0), (0, 4))) - ); - assert!(app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_finalized)); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert!(app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn copy_on_select_disabled_retains_double_clicked_word_until_shortcut() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - app.state.copy_on_select = false; - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y; - - double_click(&mut app, col, row); - - assert_visible_selection(&app); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert!(app.event_rx.try_recv().is_err()); - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - )); - - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn new_drag_cancels_stale_double_click_highlight_deadline() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - let row = info.inner_rect.y; - let word_col = info.inner_rect.x + 2; - - double_click(&mut app, word_col, row); - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - let stale_deadline = app - .selection_highlight_clear_deadline - .expect("double-click highlight deadline"); - app.state.copy_on_select = false; - - let start_col = info.inner_rect.x + 6; - let end_col = info.inner_rect.x + 9; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - assert!(app.selection_highlight_clear_deadline.is_none()); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - - assert_visible_selection(&app); - assert!(!app - .clear_due_selection_highlight(stale_deadline + std::time::Duration::from_millis(1))); - assert_visible_selection(&app); - assert!(app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn ignored_left_down_keeps_double_click_highlight_deadline() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y; - - double_click(&mut app, col, row); - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - let deadline = app - .selection_highlight_clear_deadline - .expect("double-click highlight deadline"); - app.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "finished".into(), - context: "missing".into(), - position: None, - target: Some(crate::app::state::ToastTarget { - workspace_id: "missing".into(), - pane_id: info.id, - }), - }); - app.state.view.toast_hit_area = Rect::new(0, 0, 1, 1); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 0)); - - assert_visible_selection(&app); - assert_eq!(app.selection_highlight_clear_deadline, Some(deadline)); - assert!(app.clear_due_selection_highlight(deadline + std::time::Duration::from_millis(1))); - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn double_click_uses_display_columns_for_wide_chars() { - let (mut app, info) = app_with_screen_bytes("echo 你好-world done".as_bytes()); - let col = info.inner_rect.x + 8; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - - assert_eq!(clipboard_write_content(&mut app), "你好-world".as_bytes()); - assert_visible_selection(&app); - } - - #[tokio::test] - async fn double_click_copies_quoted_path_without_quotes() { - let line = r#"cat "/tmp/build output/log.txt""#; - let (mut app, info) = app_with_screen_bytes(line.as_bytes()); - let col = info.inner_rect.x + line.find("output").expect("path segment") as u16; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - - assert_eq!( - clipboard_write_content(&mut app), - b"/tmp/build output/log.txt" - ); - assert_visible_selection(&app); - } - - #[tokio::test] - async fn double_click_excludes_trailing_punctuation() { - let (mut app, info) = app_with_screen_bytes(b"done."); - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - - assert_eq!(clipboard_write_content(&mut app), b"done"); - assert_visible_selection(&app); - } - - #[tokio::test] - async fn modified_pane_click_does_not_seed_double_click_copy() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - let col = info.inner_rect.x + 7; - let row = info.inner_rect.y; - - app.handle_mouse(modified_mouse( - MouseEventKind::Down(MouseButton::Left), - col, - row, - KeyModifiers::CONTROL, - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row)); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - - assert!(app.event_rx.try_recv().is_err()); - assert!(app.selection_highlight_clear_deadline.is_none()); - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn ctrl_click_url_reaps_failed_opener() { - let opener_dir = unique_temp_path("url-opener"); - let record_path = opener_dir.join("record"); - let opener_path = opener_dir.join("xdg-open"); - std::fs::create_dir_all(&opener_dir).expect("fake opener directory"); - std::fs::write( - &opener_path, - "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$1\" > \"$2\"\nexit 3\n", - ) - .expect("fake opener script"); - - let url = "https://example.com/akbash-2903"; - let line = format!("see {url}"); - let (mut app, info) = app_with_screen_bytes(line.as_bytes()); - let col = info.inner_rect.x + line.find("example").expect("url host") as u16; - let handled = app.handle_modified_url_click_with( - 41, - modified_mouse( - MouseEventKind::Down(MouseButton::Left), - col, - info.inner_rect.y, - KeyModifiers::CONTROL, - ), - |clicked_url| { - std::process::Command::new("/bin/sh") - .arg(&opener_path) - .arg(clicked_url) - .arg(&record_path) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .map(Some) - }, - ); - assert!(handled); - - let record = wait_for_file(&record_path); - let mut lines = record.lines(); - let pid = lines - .next() - .expect("opener pid") - .parse::() - .expect("numeric opener pid"); - assert_eq!(lines.next(), Some(url)); - - let reaped = wait_for_detached_process_reap(&mut app, pid).await; - if !reaped { - unsafe { - libc::waitpid(pid as libc::pid_t, std::ptr::null_mut(), 0); - } - } - - let _ = std::fs::remove_dir_all(&opener_dir); - assert!(reaped, "failed URL opener child {pid} was not reaped"); - } - - #[cfg(unix)] - #[tokio::test] - async fn ctrl_click_url_does_not_forward_release_to_mouse_reporting_pane() { - let line = "see https://github.com/herdrdev/herdr/issues/1761"; - let col = line.find("github").expect("url host") as u16; - let (mut app, info) = app_with_screen_bytes(b""); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}"); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - screen.as_bytes(), - 4, - ); - app.state.insert_test_runtime(pane_id, runtime); - install_test_link_handler(&mut app); - let mut send_mouse = |source_id, kind, column, modifiers| { - app.handle_mouse_from_input_source( - source_id, - modified_mouse(kind, column, info.inner_rect.y, modifiers), - ); - }; - let down = MouseEventKind::Down(MouseButton::Left); - let up = MouseEventKind::Up(MouseButton::Left); - let url_x = info.inner_rect.x + col; - - send_mouse(41, down, url_x, KeyModifiers::CONTROL); - send_mouse(42, down, info.inner_rect.x, KeyModifiers::empty()); - send_mouse(42, up, info.inner_rect.x, KeyModifiers::empty()); - send_mouse(41, up, url_x, KeyModifiers::empty()); - - assert_eq!(app.state.plugin_command_logs.len(), 1); - assert_eq!( - input_rx.try_recv().expect("other source mouse down"), - Bytes::from_static(b"\x1b[<0;1;1M") - ); - assert_eq!( - input_rx.try_recv().expect("other source mouse up"), - Bytes::from_static(b"\x1b[<0;1;1m") - ); - assert!( - input_rx.try_recv().is_err(), - "handled URL click must not leave an unmatched release for the pane" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn outer_focus_loss_does_not_forward_pending_url_click_release_to_pane() { - let line = "see https://github.com/herdrdev/herdr/issues/1761"; - let col = line.find("github").expect("url host") as u16; - let (mut app, info) = app_with_screen_bytes(b""); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}"); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - screen.as_bytes(), - 4, - ); - app.state.insert_test_runtime(pane_id, runtime); - install_test_link_handler(&mut app); - let url_x = info.inner_rect.x + col; - - app.handle_mouse_from_input_source( - 41, - modified_mouse( - MouseEventKind::Down(MouseButton::Left), - url_x, - info.inner_rect.y, - KeyModifiers::CONTROL, - ), - ); - assert_eq!(app.state.plugin_command_logs.len(), 1); - - // Opening the URL raises the browser, so the host terminal loses focus - // while the button is still down. - app.route_client_events_from( - 41, - vec![crate::raw_input::RawInputEvent::OuterFocusLost], - false, - ); - - app.handle_mouse_from_input_source( - 41, - modified_mouse( - MouseEventKind::Up(MouseButton::Left), - url_x, - info.inner_rect.y, - KeyModifiers::empty(), - ), - ); - - assert!( - input_rx.try_recv().is_err(), - "focus loss must not clear a pending URL click, so its release must stay out of the pane" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn ctrl_click_osc8_file_url_invokes_plugin_link_handler() { - let uri = "file:///tmp/herdr-file-repro.txt"; - let screen = format!("\x1b]8;;{uri}\x1b\\FILE\x1b]8;;\x1b\\"); - let (mut app, info) = app_with_screen_bytes(screen.as_bytes()); - install_test_link_handler(&mut app); - app.state - .installed_plugins - .get_mut("example.links") - .expect("test plugin") - .link_handlers[0] - .pattern = r"^file:///tmp/herdr-file-repro\.txt$".into(); - - let handled = app.handle_modified_url_click_with( - 41, - modified_mouse( - MouseEventKind::Down(MouseButton::Left), - info.inner_rect.x + 1, - info.inner_rect.y, - KeyModifiers::CONTROL, - ), - |_| panic!("matched file link should not use the system URL opener"), - ); - - assert!(handled); - let log = app - .state - .plugin_command_logs - .last() - .expect("ctrl-click should start plugin link handler"); - assert_eq!(log.plugin_id, "example.links"); - assert_eq!(log.action_id.as_deref(), Some("open")); - - let (mut unmatched_app, unmatched_info) = app_with_screen_bytes(screen.as_bytes()); - install_test_link_handler(&mut unmatched_app); - let unmatched_handled = unmatched_app.handle_modified_url_click_with( - 42, - modified_mouse( - MouseEventKind::Down(MouseButton::Left), - unmatched_info.inner_rect.x + 1, - unmatched_info.inner_rect.y, - KeyModifiers::CONTROL, - ), - |_| panic!("unmatched file link should not use the system URL opener"), - ); - - assert!(!unmatched_handled); - assert!(unmatched_app.state.plugin_command_logs.is_empty()); - } - - #[cfg(unix)] - #[tokio::test] - async fn ctrl_click_url_invokes_plugin_link_handler_but_super_click_does_not() { - let line = "see https://github.com/herdrdev/herdr/issues/398"; - let col = line.find("github").expect("url host") as u16; - - let (mut ctrl_app, ctrl_info) = app_with_screen_bytes(line.as_bytes()); - install_test_link_handler(&mut ctrl_app); - ctrl_app.handle_mouse(modified_mouse( - MouseEventKind::Down(MouseButton::Left), - ctrl_info.inner_rect.x + col, - ctrl_info.inner_rect.y, - KeyModifiers::CONTROL, - )); - - let ctrl_log = ctrl_app - .state - .plugin_command_logs - .last() - .expect("ctrl-click should start plugin link handler"); - assert_eq!(ctrl_log.plugin_id, "example.links"); - assert_eq!(ctrl_log.action_id.as_deref(), Some("open")); - - let (mut super_app, super_info) = app_with_screen_bytes(line.as_bytes()); - install_test_link_handler(&mut super_app); - super_app.handle_mouse(modified_mouse( - MouseEventKind::Down(MouseButton::Left), - super_info.inner_rect.x + col, - super_info.inner_rect.y, - KeyModifiers::SUPER, - )); - - assert!(super_app.state.plugin_command_logs.is_empty()); - } - - #[tokio::test] - async fn pane_cell_url_resolver_finds_visible_url() { - let line = "see https://example.com/pr/307."; - let (app, info) = app_with_screen_bytes(line.as_bytes()); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let col = line.find("example").expect("url host") as u16; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, col) - .as_deref(), - Some("https://example.com/pr/307") - ); - assert_eq!( - app.state.url_at_pane_cell( - &app.terminal_runtimes, - pane_id, - 0, - info.inner_rect.width - 1 - ), - None - ); - } - - #[tokio::test] - async fn pane_cell_url_resolver_finds_soft_wrapped_url() { - let (_app, info) = app_with_screen_bytes(b""); - let prefix = "https://example.com/"; - let padding = "a".repeat(info.inner_rect.width as usize - prefix.len()); - let url = format!("{prefix}{padding}tail"); - let (app, _info) = app_with_screen_bytes(url.as_bytes()); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1) - .as_deref(), - Some(url.as_str()) - ); - } - - #[tokio::test] - async fn pane_cell_url_resolver_does_not_shift_after_zero_width_mark() { - let url = "https://example.com/mark"; - let screen = format!("e\u{301} {url}"); - let (app, _info) = app_with_screen_bytes(screen.as_bytes()); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 2) - .as_deref(), - Some(url) - ); - } - - #[tokio::test] - async fn pane_cell_url_resolver_handles_hard_newline_after_full_row() { - let (_app, info) = app_with_screen_bytes(b""); - let full_row = "x".repeat(info.inner_rect.width as usize); - let url = "https://example.com/next"; - let screen = format!("{full_row}\n{url}"); - let (app, _info) = app_with_screen_bytes(screen.as_bytes()); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1) - .as_deref(), - None - ); - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 2, 1) - .as_deref(), - Some(url) - ); - } - - #[tokio::test] - async fn render_stream_does_not_synthesize_soft_wrapped_url_hyperlinks() { - let (_app, info) = app_with_screen_bytes(b""); - let prefix = "https://example.com/"; - let padding = "b".repeat(info.inner_rect.width as usize - prefix.len()); - let url = format!("{prefix}{padding}tail"); - let (app, _info) = app_with_screen_bytes(url.as_bytes()); - - let links = - crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - - assert!(links.is_empty()); - } - - #[tokio::test] - async fn render_stream_does_not_synthesize_url_hyperlinks_after_zero_width_mark() { - let url = "https://example.com/mark"; - let screen = format!("e\u{301} {url}"); - let (app, _info) = app_with_screen_bytes(screen.as_bytes()); - - let links = - crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - - assert!(links.is_empty()); - } - - #[tokio::test] - async fn render_stream_does_not_synthesize_hard_newline_plain_url_hyperlinks() { - let (_app, info) = app_with_screen_bytes(b""); - let full_row = "x".repeat(info.inner_rect.width as usize); - let url = "https://example.com/next"; - let screen = format!("{full_row}\n{url}"); - let (app, _info) = app_with_screen_bytes(screen.as_bytes()); - let links = - crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - - assert!(links.is_empty()); - } - - #[tokio::test] - async fn render_stream_exports_osc8_hyperlink_metadata() { - let uri = "https://example.com/target"; - let (mut app, _info) = - app_with_screen_bytes(format!("\x1b]8;;{uri}\x1b\\label\x1b]8;;\x1b\\").as_bytes()); - let (buffer, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut app.state, - &app.terminal_runtimes, - ratatui::layout::Rect::new(0, 0, 106, 20), - false, - crate::kitty_graphics::HostCellSize::default(), - ); - let links = - crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - let frame = crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks( - &buffer, cursor, &links, - ); - let ((x, y), symbol, _) = links - .iter() - .find(|(_, symbol, link_uri)| symbol == "l" && link_uri == uri) - .expect("OSC 8 link cell"); - let linked_cell_index = usize::from(*y) * usize::from(frame.width) + usize::from(*x); - - assert_eq!(frame.hyperlinks, vec![uri.to_owned()]); - assert_eq!(symbol, "l"); - assert_eq!(frame.cells[linked_cell_index].hyperlink, Some(0)); - } - - #[tokio::test] - async fn pane_cell_url_resolver_prefers_osc8_hyperlink() { - let (app, _info) = app_with_screen_bytes( - b"\x1b]8;;https://example.com/hidden-target\x1b\\label\x1b]8;;\x1b\\", - ); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 1) - .as_deref(), - Some("https://example.com/hidden-target") - ); - } - - #[tokio::test] - async fn double_click_highlight_clears_after_short_delay() { - let (mut app, info) = app_with_screen_bytes(b"copied"); - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - assert_eq!(clipboard_write_content(&mut app), b"copied"); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row)); - - assert!(app.event_rx.try_recv().is_err()); - assert!(app.state.selection.is_some()); - let deadline = app - .selection_highlight_clear_deadline - .expect("highlight clear deadline"); - assert!(app.clear_due_selection_highlight(deadline + std::time::Duration::from_millis(1))); - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn copy_on_select_disabled_still_forwards_mouse_reporting_gestures() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - app.state.copy_on_select = false; - - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y + 3; - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - col + 1, - row + 1, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - col + 1, - row + 1, - )); - - assert!(app.event_rx.try_recv().is_err()); - assert!(app.state.selection.is_none()); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert_eq!( - input_rx.try_recv().expect("forwarded left mouse down"), - Bytes::from_static(b"\x1b[<0;3;4M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded left mouse drag"), - Bytes::from_static(b"\x1b[<32;4;5M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded left mouse up"), - Bytes::from_static(b"\x1b[<0;4;5m") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app - .state - .runtime_for_pane(&app.terminal_runtimes, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - let top_row = info.inner_rect.y; - let col = info.inner_rect.x + 2; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row)); - app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row)); - - let end_metrics = app - .state - .runtime_for_pane(&app.terminal_runtimes, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after wheel"); - assert_eq!( - end_metrics.offset_from_bottom, - start_metrics.offset_from_bottom + 3 - ); - - let selection = app.state.selection.as_ref().expect("selection after wheel"); - assert!(selection.is_visible()); - assert_eq!( - selection.ordered_cells(), - ( - ( - (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, - 2, - ), - (start_metrics.max_offset_from_bottom as u32, 2), - ) - ); - } - - #[tokio::test] - async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(Rect::new(0, 0, 80, 24)); - let focused_before = app.state.workspaces[0].layout.focused(); - app.state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+h"); - - app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT)) - .await; - - assert_ne!(app.state.workspaces[0].layout.focused(), focused_before); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[cfg(unix)] - #[tokio::test] - async fn terminal_direct_edit_scrollback_opens_editor_pane() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - let mut workspace = Workspace::test_new("test"); - let root_pane = workspace.tabs[0].root_pane; - workspace.tabs[0].runtimes.insert( - root_pane, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - 20, - 5, - 4096, - b"alpha\nbeta\n", - ), - ); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("direct-edit-scrollback"); - let previous_editor = std::env::var_os("EDITOR"); - std::env::set_var( - "EDITOR", - format!("sh -c 'cp \"$1\" {}' sh", output_path.display()), - ); - app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::direct("ctrl+alt+e"); - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('e'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - match previous_editor { - Some(value) => std::env::set_var("EDITOR", value), - None => std::env::remove_var("EDITOR"), - } - - let content = wait_for_file(&output_path); - assert!(content.contains("alpha")); - assert!(content.contains("beta")); - assert_eq!(app.state.mode, Mode::Terminal); - - let _ = std::fs::remove_file(output_path); - } - - #[cfg(unix)] - #[tokio::test] - async fn direct_custom_command_runs_before_forwarding_to_pane() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("direct-custom-command"); - let command = format!("printf direct > '{}'", output_path.display()); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), - label: "ctrl+alt+g".into(), - command, - action: crate::config::CustomCommandAction::Shell, - description: None, - width: None, - height: None, - }]; - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('g'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - assert_eq!(wait_for_file(&output_path), "direct"); - assert_eq!(app.state.mode, Mode::Terminal); - let _ = std::fs::remove_file(output_path); - } - - #[cfg(unix)] - #[tokio::test] - async fn direct_custom_pane_command_opens_overlay_pane() { - let mut app = app_with_spawned_workspace(); - - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), - label: "ctrl+alt+g".into(), - command: "printf direct-pane".into(), - action: crate::config::CustomCommandAction::Pane, - description: None, - width: None, - height: None, - }]; - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('g'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert!(app.state.workspaces[0].tabs[0].zoomed); - assert_eq!(app.state.mode, Mode::Terminal); - - shutdown_test_runtimes(&mut app); - } - - #[cfg(unix)] - #[tokio::test] - async fn direct_custom_popup_command_opens_layout_neutral_popup() { - let mut app = app_with_spawned_workspace(); - - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), - label: "ctrl+alt+g".into(), - command: "sleep 1".into(), - action: crate::config::CustomCommandAction::Popup, - description: None, - width: Some(crate::popup_size::PopupSize::Cells(60)), - height: Some(crate::popup_size::PopupSize::Cells(12)), - }]; - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('g'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - assert!(app.state.popup_pane.is_some()); - assert!(!app - .popup_runtime() - .unwrap() - .agent_detection_enabled_for_test()); - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert!(!app.state.workspaces[0].tabs[0].zoomed); - assert_eq!(app.state.mode, Mode::Terminal); - let snapshot = crate::persist::capture( - &app.state.workspaces, - &app.state.terminals, - &app.terminal_runtimes, - app.state.active, - app.state.selected, - app.state.sidebar_width, - app.state.sidebar_section_split, - app.state.collapsed_space_keys.clone(), - ); - assert_eq!(snapshot.workspaces[0].tabs[0].panes.len(), 1); - assert!(matches!( - snapshot.workspaces[0].tabs[0].layout, - crate::persist::LayoutSnapshot::Pane(_) - )); - - shutdown_test_runtimes(&mut app); - } - - #[cfg(unix)] - #[tokio::test] - async fn direct_custom_popup_command_closes_after_exit() { - let mut app = app_with_spawned_workspace(); - let focused_pane = app.state.workspaces[0].focused_pane_id().unwrap(); - let focused_pane_id = app.public_pane_id(0, focused_pane).unwrap(); - - let output_path = unique_temp_path("custom-popup-command"); - let command = format!( - "printf '%s|%s' \"${{HERDR_PANE_ID-unset}}\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'", - output_path.display() - ); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), - label: "ctrl+alt+g".into(), - command, - action: crate::config::CustomCommandAction::Popup, - description: None, - width: None, - height: None, - }]; - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('g'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - assert_eq!( - wait_for_file(&output_path), - format!("unset|{focused_pane_id}") - ); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while std::time::Instant::now() < deadline { - app.drain_internal_events(); - if app.state.popup_pane.is_none() { - break; - } - } - - assert!(app.state.popup_pane.is_none()); - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - - shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_file(output_path); - } - - #[tokio::test] - async fn popup_forwards_escape_instead_of_closing() { - let mut app = app_for_mouse_test(); - let (runtime, mut rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 40, - 2, - 1024, - b"one\r\ntwo\r\nthree\r\n", - 4, - ); - runtime.scroll_up(1); - assert!(runtime - .scroll_metrics() - .is_some_and(|metrics| metrics.offset_from_bottom > 0)); - app.install_test_popup_runtime(runtime); - app.state.mode = Mode::Settings; - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - - assert_eq!(rx.try_recv().unwrap().as_ref(), b"\x1b"); - assert!(app.state.popup_pane.is_some()); - assert_eq!( - app.popup_runtime() - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .map(|metrics| metrics.offset_from_bottom), - Some(0) - ); - } - - #[tokio::test] - async fn alt_backspace_is_forwarded_to_focused_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 80, 24)); - let info = pane_infos[0].clone(); - let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel( - info.inner_rect.width, - info.inner_rect.height, - ); - ws.tabs[0].runtimes.insert(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let key = crate::input::parse_terminal_key_sequence("\x1b\x7f").unwrap(); - app.handle_terminal_key_headless(key); - - let bytes = rx.try_recv().unwrap(); - assert_eq!(bytes.as_ref(), b"\x1b\x7f"); - assert!(rx.try_recv().is_err()); - } - - fn app_with_plain_scrollback( - line_count: usize, - ) -> (App, crate::layout::PaneId, crate::layout::PaneInfo) { - let mut app = app_for_mouse_test(); - let mut workspace = Workspace::test_new("test"); - let pane_id = workspace.tabs[0].root_pane; - let pane_infos = workspace.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let pane_info = pane_infos[0].clone(); - workspace.tabs[0].runtimes.insert( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - pane_info.inner_rect.width, - pane_info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(line_count), - ), - ); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - (app, pane_id, pane_info) - } - - fn pane_scroll_offset(app: &App, pane_id: crate::layout::PaneId) -> usize { - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("pane scroll metrics") - .offset_from_bottom - } - - fn physical_page_up(repeat_count: u16) -> TerminalKey { - TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()).with_windows_record( - crate::input::WindowsKeyRecord { - key_down: true, - repeat_count, - virtual_key_code: 0x21, - virtual_scan_code: 0x49, - unicode: 0, - control_key_state: 0x0100, - }, - ) - } - - #[tokio::test] - async fn page_up_scrolls_plain_shell_pane() { - let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64); - assert_eq!(pane_scroll_offset(&app, pane_id), 0); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize - ); - } - - #[tokio::test] - async fn consumed_page_up_preserves_separate_and_grouped_repeats() { - let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256); - let page_up = physical_page_up(1); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key(page_up.clone()), - crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - ), - crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Release), - ), - ], - false, - ); - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize * 2 - ); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(physical_page_up(3))], - false, - ); - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize * 5 - ); - } - - #[tokio::test] - async fn consumed_repeat_that_becomes_forwarded_acquires_pane_ownership() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal); - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let first_info = pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info"); - ws.tabs[0].runtimes.insert( - first_pane, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - first_info.inner_rect.width, - first_info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(128), - ), - ); - let (second_runtime, mut second_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[?1h\x1b[>15u", - 3, - ); - ws.tabs[0].runtimes.insert(second_pane, second_runtime); - ws.tabs[0].layout.focus_pane(first_pane); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let page_up = physical_page_up(1); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(page_up.clone())], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, second_pane)); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - )], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, first_pane)); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - ), - crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)), - ], - false, - ); - - let first_repeat = second_rx.try_recv().expect("first forwarded repeat"); - let second_repeat = second_rx.try_recv().expect("owned repeat"); - let release = second_rx.try_recv().expect("owned release"); - assert_eq!(first_repeat, second_repeat); - assert_ne!(second_repeat, release); - assert!(second_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn missing_popup_runtime_suppresses_grouped_and_later_repeats() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); - ws.tabs[0].runtimes.insert(pane_id, runtime); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let (popup_runtime, _popup_rx) = - crate::terminal::TerminalRuntime::test_with_channel(40, 12); - app.install_test_popup_runtime(popup_runtime); - let popup_terminal_id = app - .state - .popup_pane - .as_ref() - .expect("popup installed") - .terminal_id - .clone(); - app.terminal_runtimes.remove(&popup_terminal_id); - - let key = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()).with_repeat_count(3); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key(key.clone()), - crate::raw_input::RawInputEvent::Key( - key.clone() - .with_kind(KeyEventKind::Repeat) - .with_repeat_count(1), - ), - crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Release)), - ], - false, - ); - - assert!(app.state.popup_pane.is_none()); - assert!(input_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn consumed_repeat_stays_suppressed_after_context_returns() { - let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(128); - let page_up = physical_page_up(1); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(page_up.clone())], - false, - ); - let after_press = pane_scroll_offset(&app, pane_id); - - let (popup_runtime, mut popup_rx) = - crate::terminal::TerminalRuntime::test_with_channel(40, 12); - app.install_test_popup_runtime(popup_runtime); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - )], - false, - ); - app.close_popup_pane(); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - ), - crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)), - ], - false, - ); - - assert_eq!(pane_scroll_offset(&app, pane_id), after_press); - assert!(popup_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn page_down_returns_to_bottom_after_page_up() { - let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - assert!(pane_scroll_offset(&app, pane_id) > 0); - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::PageDown, - KeyModifiers::empty(), - )); - assert_eq!(pane_scroll_offset(&app, pane_id), 0); - } - - #[tokio::test] - async fn page_up_release_does_not_scroll_plain_shell_pane_again() { - let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let after_press = pane_scroll_offset(&app, pane_id); - assert_eq!(after_press, pane_info.inner_rect.height as usize); - - app.handle_terminal_key_headless( - TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()) - .with_kind(KeyEventKind::Release), - ); - - assert_eq!(pane_scroll_offset(&app, pane_id), after_press); - } - - #[tokio::test] - async fn modified_page_up_does_not_host_scroll_plain_shell_pane() { - let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::CONTROL)); - - assert_eq!(pane_scroll_offset(&app, pane_id), 0); - } - - #[tokio::test] - async fn page_up_forwarded_to_mouse_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let mut bytes = b"\x1b[?1002h".to_vec(); - bytes.extend_from_slice(&numbered_lines_bytes(64)); - ws.tabs[0].runtimes.insert( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &bytes, - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - assert_eq!(start_metrics.offset_from_bottom, 0); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - let end_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp"); - // Forwarded to pane, so test runtime doesn't process it — scroll stays at bottom. - assert_eq!(end_metrics.offset_from_bottom, 0); - } - - #[tokio::test] - async fn page_up_forwarded_to_primary_screen_application_cursor_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let mut bytes = b"\x1b[?1h".to_vec(); - bytes.extend_from_slice(&numbered_lines_bytes(64)); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &bytes, - 4, - ); - ws.tabs[0].runtimes.insert(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - assert_eq!(start_metrics.offset_from_bottom, 0); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - let forwarded = input_rx.try_recv().expect("forwarded PageUp"); - assert_eq!(forwarded.as_ref(), b"\x1b[5~"); - let end_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp"); - assert_eq!(end_metrics.offset_from_bottom, 0); - } - - #[tokio::test] - async fn page_up_scrolls_shell_like_decckm_with_bracketed_paste() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - // zsh enables DECCKM via smkx and bracketed paste together; bash/fish do not. - let mut bytes = b"\x1b[?1h\x1b[?2004h".to_vec(); - bytes.extend_from_slice(&numbered_lines_bytes(64)); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &bytes, - 4, - ); - ws.tabs[0].runtimes.insert(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - assert!( - input_rx.try_recv().is_err(), - "PageUp should not reach the shell" - ); - assert_eq!( - pane_scroll_offset(&app, pane_id), - info.inner_rect.height as usize - ); - } -} diff --git a/src/app/mod.rs b/src/app/mod.rs index 6fa97f4f..90ee33b8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2,7 +2,6 @@ //! //! - `state.rs` — AppState, Mode, and pure data structs //! - `actions.rs` — state mutations (testable without PTYs/async) -//! - `input.rs` — key/mouse → action translation pub(crate) mod actions; mod agent_resume; @@ -12,16 +11,13 @@ pub(crate) use agents::{AGENT_START_SETTLE_DELAY, MAX_AGENT_START_TIMEOUT}; mod api; mod api_helpers; pub(crate) use api_helpers::limit_snapshot_lines; -mod config_io; mod creation; mod custom_commands; mod git_refresh; mod ids; -pub(crate) mod input; pub(crate) mod pane_graphics; mod popup; mod runtime; -mod runtime_mutations; mod session; pub mod state; mod tab_bar_status; @@ -31,23 +27,18 @@ mod theme_sync; mod window_title; mod worktrees; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; #[cfg(unix)] use std::io; use std::sync::Arc; use std::time::{Duration, Instant}; const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); -pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const GIT_REPO_DISCOVERY_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); const PENDING_AGENT_RESUME_THEME_WAIT: Duration = Duration::from_millis(750); const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5); -const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); -const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); -const PANE_COPY_HIGHLIGHT_DURATION: Duration = Duration::from_millis(500); -const COPY_FEEDBACK_DURATION: Duration = Duration::from_secs(2); use ratatui::layout::Rect; use tokio::sync::{mpsc, Notify}; @@ -75,21 +66,37 @@ pub(crate) struct OverlayPaneState { temp_files: Vec, } -#[derive(Debug, Clone, Copy)] -pub(crate) struct PaneClickState { - pane_id: crate::layout::PaneId, - viewport_row: u16, - col: u16, - at: Instant, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AppPolicy { + pub(crate) restore_session: bool, + pub(crate) persist_session: bool, + pub(crate) persist_plugin_registry: bool, + pub(crate) background_updates: bool, } -impl PaneClickState { - fn is_double_click_for(self, next: Self) -> bool { - self.pane_id == next.pane_id - && next.at.duration_since(self.at) <= PANE_DOUBLE_CLICK_WINDOW - && self.viewport_row.abs_diff(next.viewport_row) <= 1 - && self.col.abs_diff(next.col) <= 1 - } +impl AppPolicy { + pub(crate) const PRODUCTION: Self = Self { + restore_session: true, + persist_session: true, + persist_plugin_registry: true, + background_updates: true, + }; + + #[cfg(test)] + pub(crate) const TEST: Self = Self { + restore_session: false, + persist_session: false, + persist_plugin_registry: false, + background_updates: false, + }; + + #[cfg(unix)] + pub(crate) const HANDOFF_REPLACEMENT: Self = Self { + restore_session: false, + persist_session: true, + persist_plugin_registry: true, + background_updates: true, + }; } pub struct App { @@ -104,10 +111,9 @@ pub struct App { pub(crate) api_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) event_hub: crate::api::EventHub, pub(crate) last_focus: Option<(usize, crate::layout::PaneId)>, - pub(crate) no_session: bool, + pub(crate) policy: AppPolicy, pub(crate) config_diagnostic_deadline: Option, pub(crate) toast_deadline: Option, - pub(crate) copy_feedback_deadline: Option, pub(crate) last_api_notification_at: Option, pub(crate) last_git_remote_status_refresh: Instant, pub(crate) last_git_repo_discovery_refresh: Instant, @@ -119,9 +125,6 @@ pub struct App { pub(crate) pending_api_worktree_removes: HashMap, pub(crate) pending_api_worktree_remove_paths: HashMap, pub(crate) next_api_worktree_operation_id: u64, - pub(crate) last_sidebar_divider_click: Option, - pub(crate) last_pane_click: Option, - pub(crate) pending_url_click_sources: HashSet, pub(crate) next_auto_update_check: Option, pub(crate) next_agent_manifest_update_check: Option, pub(crate) update_version_check_enabled: bool, @@ -129,8 +132,6 @@ pub struct App { pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig, pub(crate) agent_metadata_deadline: Option, pub(crate) pending_agent_resume_deadline: Option, - pub(crate) selection_autoscroll_deadline: Option, - pub(crate) selection_highlight_clear_deadline: Option, pub(crate) session_save_deadline: Option, pub(crate) session_save_thread: Option>, pub(crate) detached_process_children: Vec, @@ -145,7 +146,6 @@ pub struct App { pub(crate) last_render_at: Option, /// Last attempt that could update a connected presentation surface. pub(crate) last_presentation_at: Option, - pub(crate) input_leases: input::InputLeaseTable, pub render_notify: Arc, pub(crate) render_dirty: Arc, pub(crate) full_redraw_pending: bool, @@ -158,30 +158,18 @@ pub struct App { pub(crate) const APP_EVENT_CHANNEL_CAPACITY: usize = 256; pub(crate) const APP_EVENT_DRAIN_LIMIT: usize = 64; -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct TerminalInputTarget { - terminal_id: crate::terminal::TerminalId, +fn auto_updates_enabled(background_updates: bool) -> bool { + background_updates && !cfg!(debug_assertions) } -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum TerminalInputContext { - Pane, - Popup(crate::terminal::TerminalId), +fn background_update_check_enabled(background_updates: bool, check_enabled: bool) -> bool { + auto_updates_enabled(background_updates) && check_enabled } -pub(crate) type InputSourceId = u64; -const LOCAL_INPUT_SOURCE: InputSourceId = 0; - -fn auto_updates_enabled(no_session: bool) -> bool { - !no_session && !cfg!(debug_assertions) -} - -fn background_update_check_enabled(no_session: bool, check_enabled: bool) -> bool { - auto_updates_enabled(no_session) && check_enabled -} - -fn load_plugin_registry(no_session: bool) -> crate::app::state::InstalledPluginRegistry { - if no_session { +fn load_plugin_registry( + persist_plugin_registry: bool, +) -> crate::app::state::InstalledPluginRegistry { + if !persist_plugin_registry { return std::collections::HashMap::new(); } let entries = crate::persist::plugin_registry::load(); @@ -363,7 +351,7 @@ pub(crate) fn client_palette_for_appearance( impl App { pub fn new( config: &Config, - no_session: bool, + policy: AppPolicy, config_diagnostic: Option, api_rx: tokio::sync::mpsc::UnboundedReceiver, event_hub: crate::api::EventHub, @@ -377,24 +365,8 @@ impl App { // Try to restore previous session let mut restored_terminals = std::collections::HashMap::new(); let mut restored_terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let ( - workspaces, - active, - selected, - sidebar_width, - sidebar_width_source, - sidebar_section_split, - collapsed_space_keys, - ) = if no_session { - ( - Vec::new(), - None, - 0, - config.ui.sidebar_width, - state::SidebarWidthSource::ConfigDefault, - 0.5_f32, - std::collections::HashSet::new(), - ) + let (workspaces, active, selected) = if !policy.restore_session { + (Vec::new(), None, 0) } else if let Some(snap) = crate::persist::load() { let history = config .experimental @@ -418,67 +390,19 @@ impl App { restored_terminal_runtimes = terminal_runtimes.into(); if ws.is_empty() { crate::logging::session_restored(0, "empty"); - ( - Vec::new(), - None, - 0, - snap.sidebar_width.unwrap_or(config.ui.sidebar_width), - if snap.sidebar_width.is_some() { - state::SidebarWidthSource::Persisted - } else { - state::SidebarWidthSource::ConfigDefault - }, - snap.sidebar_section_split.unwrap_or(0.5), - snap.collapsed_space_keys, - ) + (Vec::new(), None, 0) } else { crate::logging::session_restored(ws.len(), "ok"); let active = snap.active.filter(|&i| i < ws.len()); let selected = snap.selected.min(ws.len().saturating_sub(1)); - ( - ws, - active, - selected, - snap.sidebar_width.unwrap_or(config.ui.sidebar_width), - if snap.sidebar_width.is_some() { - state::SidebarWidthSource::Persisted - } else { - state::SidebarWidthSource::ConfigDefault - }, - snap.sidebar_section_split.unwrap_or(0.5), - snap.collapsed_space_keys, - ) + (ws, active, selected) } } else { - ( - Vec::new(), - None, - 0, - config.ui.sidebar_width, - state::SidebarWidthSource::ConfigDefault, - 0.5_f32, - std::collections::HashSet::new(), - ) + (Vec::new(), None, 0) }; let agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); - // Validate sidebar bounds before they reach any `u16::clamp(min, max)` - // call: `clamp` panics when `min > max`. On bad config, fall back to - // the built-in defaults rather than crashing on the first render. - let (sidebar_min_width, sidebar_max_width) = crate::config::validated_sidebar_bounds( - config.ui.sidebar_min_width, - config.ui.sidebar_max_width, - ) - .unwrap_or_else(|| { - tracing::warn!( - min = config.ui.sidebar_min_width, - max = config.ui.sidebar_max_width, - "ui.sidebar_min_width is greater than sidebar_max_width; falling back to default bounds (18, 36)" - ); - (18, 36) - }); - let worktree_directory = crate::worktree::expand_tilde_absolute_path(&config.worktrees.directory); @@ -497,11 +421,7 @@ impl App { let startup_product_announcement = crate::product_announcements::load_unseen_for_current_version(); - let mode = if config.should_show_onboarding() { - state::Mode::Onboarding - } else if startup_product_announcement.is_some() { - state::Mode::ProductAnnouncement - } else if active.is_some() { + let mode = if active.is_some() { state::Mode::Terminal } else { state::Mode::Navigate @@ -527,32 +447,8 @@ impl App { selected, mode, should_quit: false, - detach_requested: false, - request_new_workspace: false, - request_new_tab: false, - request_new_linked_worktree: None, - request_open_existing_worktree: None, - request_new_workspace_cwd: None, - request_remove_linked_worktree: None, - request_submit_worktree_create: false, - request_submit_worktree_open: false, - request_submit_worktree_remove: false, - request_reload_config: false, request_client_config_reload: false, - request_clipboard_write: None, - creating_new_tab: false, - requested_new_tab_name: None, - pending_workspace_create_cwd: None, - rename_pane_target: None, - worktree_create: None, - worktree_open: None, - worktree_remove: None, worktree_directory, - collapsed_space_keys, - request_complete_onboarding: false, - name_input: String::new(), - name_input_replace_on_type: false, - release_notes: None, latest_release_notes, product_announcement: startup_product_announcement.map(|announcement| { state::ProductAnnouncementState { @@ -564,36 +460,10 @@ impl App { preview: announcement.preview, } }), - keybind_help: state::KeybindHelpState::default(), - navigator: state::NavigatorState::default(), - copy_mode: None, - workspace_scroll: 0, - agent_panel_scroll: 0, - tab_scroll: 0, - tab_scroll_follow_active: true, - mobile_switcher_scroll: 0, view: state::ViewState { - layout: state::ViewLayout::Desktop, - sidebar_rect: Rect::default(), - workspace_card_areas: Vec::new(), - tab_bar_rect: Rect::default(), - tab_hit_areas: Vec::new(), - tab_scroll_left_hit_area: Rect::default(), - tab_scroll_right_hit_area: Rect::default(), - new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), - mobile_header_rect: Rect::default(), - mobile_menu_hit_area: Rect::default(), - toast_hit_area: Rect::default(), pane_infos: Vec::new(), - split_borders: Vec::new(), }, - drag: None, - workspace_presses: HashMap::new(), - tab_presses: HashMap::new(), - selection: None, - selection_autoscroll: None, - context_menu: None, update_available, update_install_command, latest_release_notes_available, @@ -601,59 +471,32 @@ impl App { config_diagnostic, toast: None, pending_agent_notifications: std::collections::HashMap::new(), - copy_feedback: None, outer_terminal_focus: None, prefix_code, prefix_mods, headless_size: config.headless_size(), - default_sidebar_width: config.ui.sidebar_width, - sidebar_width, - sidebar_min_width, - sidebar_max_width, - mobile_width_threshold: config.ui.mobile_width_threshold, - sidebar_width_source, - sidebar_width_auto: false, - sidebar_collapsed: config.ui.sidebar_start_collapsed, - sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, - sidebar_section_split, agent_panel_sort, - status_indicators: config.ui.status_indicators, agent_view_override: None, sidebar_agents: config.ui.sidebar.agents.clone(), sidebar_spaces: config.ui.sidebar.spaces.clone(), next_agent_state_change_seq: 0, - mouse_capture: config.ui.mouse_capture, - copy_on_select: config.ui.copy_on_select, - right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(), - right_click_passthrough: None, - redraw_on_focus_gained: config.ui.redraw_on_focus_gained, - mouse_scroll_lines: config.ui.mouse_scroll_lines(), confirm_close: config.ui.confirm_close, - prompt_new_tab_name: config.ui.prompt_new_tab_name, - prompt_new_workspace_name: config.ui.prompt_new_workspace_name, pane_borders: config.ui.pane_borders, pane_outer_borders: config.ui.pane_outer_borders, pane_scrollbars: config.ui.pane_scrollbars, pane_gaps: config.ui.pane_gaps, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, - hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab, - tab_bar_position: config.ui.tab_bar_position, tab_bar_right: Vec::new(), tab_bar_right_separator: String::new(), - pane_history_persistence: config.experimental.pane_history, reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime, cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(), cjk_ime_agents: parse_cjk_ime_agents(&config.experimental.cjk_ime_agents), cjk_ime_cursor_shape: config.experimental.cjk_ime_cursor_shape.to_decscusr(), - switch_ascii_input_source_in_prefix: config - .experimental - .switch_ascii_input_source_in_prefix, kitty_graphics_enabled: config.experimental.kitty_graphics, default_shell: config.terminal.default_shell.clone(), shell_mode: config.terminal.shell_mode, new_terminal_cwd: config.terminal.new_cwd.clone(), pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes, - accent: crate::config::parse_color(&config.ui.accent), sound: config.ui.sound.clone(), toast_config: config.ui.toast.clone(), keybinds: config.keybinds(), @@ -662,29 +505,19 @@ impl App { theme_runtime, host_terminal_appearance: None, host_terminal_appearance_explicit: false, - settings: state::SettingsState { - section: state::SettingsSection::Theme, - list: state::SelectionListState::new(0), - original_palette: None, - original_theme: None, - }, integration_recommendations: crate::integration::integration_recommendations(), agent_manifest_summaries, agent_manifest_update_status: crate::detect::manifest_update::load_status(), - integration_install_messages: Vec::new(), - installed_plugins: load_plugin_registry(no_session), + installed_plugins: load_plugin_registry(policy.persist_plugin_registry), plugin_panes: std::collections::HashMap::new(), popup_pane: None, plugin_command_logs: Vec::new(), next_plugin_command_log_id: 1, plugin_commands_in_flight: 0, - global_menu: state::MenuListState::new(0), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), host_cell_size: crate::kitty_graphics::HostCellSize::default(), - host_mouse_pixels: None, session_dirty: false, terminal_runtime_shutdowns: Vec::new(), - confirm_close_workspace_id: None, }; state.terminals = restored_terminals; @@ -700,9 +533,11 @@ impl App { // and in debug/test builds so local development never mutates the // running binary out from under spawned test processes. let version_check_enabled = - background_update_check_enabled(no_session, config.update.version_check); - let manifest_check_enabled = - background_update_check_enabled(no_session, config.update.manifest_check); + background_update_check_enabled(policy.background_updates, config.update.version_check); + let manifest_check_enabled = background_update_check_enabled( + policy.background_updates, + config.update.manifest_check, + ); if version_check_enabled { let update_tx = event_tx.clone(); std::thread::spawn(move || crate::update::auto_update(update_tx)); @@ -727,7 +562,6 @@ impl App { let mut app = Self { config_diagnostic_deadline: None, toast_deadline: None, - copy_feedback_deadline: None, last_api_notification_at: None, state, pane_graphics: pane_graphics::Runtime::default(), @@ -747,9 +581,6 @@ impl App { pending_api_worktree_removes: HashMap::new(), pending_api_worktree_remove_paths: HashMap::new(), next_api_worktree_operation_id: 1, - last_sidebar_divider_click: None, - last_pane_click: None, - pending_url_click_sources: HashSet::new(), next_auto_update_check: version_check_enabled .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), next_agent_manifest_update_check: manifest_check_enabled @@ -767,16 +598,13 @@ impl App { tab_bar_commands: Vec::new(), next_tab_bar_datetime_refresh: None, window_title_template: None, - selection_autoscroll_deadline: None, - selection_highlight_clear_deadline: None, persist_pane_history: config.experimental.pane_history, last_render_at: None, last_presentation_at: None, - input_leases: input::InputLeaseTable::default(), api_rx, event_hub, last_focus, - no_session, + policy, render_notify, render_dirty, full_redraw_pending: false, @@ -802,7 +630,13 @@ impl App { crate::handoff_runtime::ImportedHandoffRuntime, >, ) -> io::Result { - let mut app = Self::new(config, true, config_diagnostic, api_rx, event_hub); + let mut app = Self::new( + config, + AppPolicy::HANDOFF_REPLACEMENT, + config_diagnostic, + api_rx, + event_hub, + ); let (workspaces, terminals, runtimes) = crate::persist::restore_handoff( snapshot, config.advanced.scrollback_limit_bytes, @@ -815,19 +649,6 @@ impl App { )?; let pane_id_aliases = crate::persist::handoff_pane_aliases(snapshot, &workspaces); - app.no_session = false; - app.state.installed_plugins = load_plugin_registry(app.no_session); - let now = Instant::now(); - if background_update_check_enabled(app.no_session, app.update_version_check_enabled) { - app.next_auto_update_check = app - .state - .update_available - .is_none() - .then_some(now + AUTO_UPDATE_CHECK_INTERVAL); - } - if background_update_check_enabled(app.no_session, app.update_manifest_check_enabled) { - app.next_agent_manifest_update_check = Some(now + AUTO_UPDATE_CHECK_INTERVAL); - } app.state.pane_id_aliases = pane_id_aliases; app.state.workspaces = workspaces; app.state.terminals = terminals; @@ -838,14 +659,6 @@ impl App { app.state.selected = snapshot .selected .min(app.state.workspaces.len().saturating_sub(1)); - if let Some(width) = snapshot.sidebar_width { - app.state.sidebar_width = width; - app.state.sidebar_width_source = state::SidebarWidthSource::Persisted; - } - if let Some(split) = snapshot.sidebar_section_split { - app.state.sidebar_section_split = split; - } - app.state.collapsed_space_keys = snapshot.collapsed_space_keys.clone(); app.state.mode = if app.state.active.is_some() { state::Mode::Terminal } else { @@ -870,59 +683,15 @@ impl App { self.terminal_runtimes.assume_handoff_ownership(); } - pub(crate) fn sync_prefix_input_source(&mut self, previous_mode: Mode) { - // Emit the input-source intent on entering/leaving the ASCII realm, like `ClipboardWrite`; - // the foreground client applies the switch. Keyed on the - // realm so multi-level prefix commands stay ASCII. The switch is flag-gated but the restore - // always fires on exit, so a mid-interaction flag toggle can't strand the host on ASCII. - let active = match ( - previous_mode.wants_ascii_input(), - self.state.mode.wants_ascii_input(), - ) { - (false, true) if self.state.switch_ascii_input_source_in_prefix => true, - (true, false) => false, - _ => return, - }; - if let Err(err) = self - .event_tx - .try_send(crate::events::AppEvent::PrefixInputSource { active }) - { - tracing::warn!(active, %err, "failed to queue prefix input-source change"); - } - } - - pub(crate) fn handle_internal_event_with_prefix_sync( - &mut self, - event: crate::events::AppEvent, - ) -> bool { - let previous_mode = self.state.mode; - let changed = self.handle_internal_event_with_render_impact(event); - self.sync_prefix_input_source(previous_mode); - changed - } - pub(crate) fn ensure_default_workspace(&mut self) -> bool { - if !self.state.workspaces.is_empty() - || self.state.mode == Mode::Onboarding - || self.state.pending_workspace_create_cwd.is_some() - { + if !self.state.workspaces.is_empty() { return false; } - let previous_mode = self.state.mode; - let preserve_mode = matches!( - previous_mode, - Mode::ReleaseNotes | Mode::ProductAnnouncement | Mode::Settings - ); let cwd = self.resolve_new_terminal_cwd(None); match self.create_workspace_with_options(cwd, true) { - Ok(_) => { - if preserve_mode { - self.state.mode = previous_mode; - } - true - } + Ok(_) => true, Err(err) => { tracing::error!(err = %err, "failed to create default workspace"); self.state.mode = Mode::Navigate; @@ -941,27 +710,6 @@ impl App { } } - pub(crate) fn dismiss_release_notes(&mut self) { - let preview = self - .state - .release_notes - .as_ref() - .is_some_and(|notes| notes.preview); - - self.state.release_notes = None; - self.mark_release_notes_seen(preview); - - if self.state.product_announcement.is_some() { - self.state.mode = Mode::ProductAnnouncement; - } else { - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - pub(crate) fn dismiss_product_announcement(&mut self) { if let Some(announcement) = self.state.product_announcement.take() { if !announcement.preview { @@ -974,87 +722,6 @@ impl App { } } } - - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - pub(crate) fn scroll_release_notes(&mut self, delta: i16) { - let max_scroll = self.state.release_notes_max_scroll(); - if let Some(notes) = &mut self.state.release_notes { - notes.scroll = if delta.is_negative() { - notes.scroll.saturating_sub(delta.unsigned_abs()) - } else { - notes.scroll.saturating_add(delta as u16) - } - .min(max_scroll); - } - } - - pub(crate) fn scroll_product_announcement(&mut self, delta: i16) { - let max_scroll = self.state.product_announcement_max_scroll(); - if let Some(announcement) = &mut self.state.product_announcement { - announcement.scroll = if delta.is_negative() { - announcement.scroll.saturating_sub(delta.unsigned_abs()) - } else { - announcement.scroll.saturating_add(delta as u16) - } - .min(max_scroll); - } - } - - pub(crate) fn open_settings_from_onboarding(&mut self) { - self.mark_onboarding_complete(); - self.refresh_integration_recommendations(); - crate::app::input::open_settings_at(&mut self.state, state::SettingsSection::Integrations); - } - - pub(crate) fn refresh_integration_recommendations(&mut self) { - self.state.integration_recommendations = crate::integration::integration_recommendations(); - } - - pub(crate) fn install_recommended_integrations(&mut self) { - let targets = self - .state - .integration_recommendations - .iter() - .filter(|recommendation| recommendation.needs_install()) - .map(|recommendation| recommendation.target) - .collect::>(); - - self.state.integration_install_messages.clear(); - if targets.is_empty() { - self.state - .integration_install_messages - .push("all detected integrations are current".to_string()); - return; - } - - for target in targets { - let label = crate::integration::integration_target_label(target); - match crate::integration::install_target(target) { - Ok(messages) => { - self.state - .integration_install_messages - .push(format!("installed {label}")); - self.state - .integration_install_messages - .extend(messages.into_iter().filter(|message| { - message.starts_with(crate::integration::INSTALL_WARNING_PREFIX) - })); - } - Err(err) => self - .state - .integration_install_messages - .push(format!("{label}: {err}")), - } - } - - self.state.integration_recommendations = crate::integration::integration_recommendations(); - self.state.mark_session_dirty(); } pub(crate) fn reload_config(&mut self) -> crate::config::ConfigReloadReport { @@ -1148,38 +815,14 @@ impl App { &config.ui.window_title, )); - self.state.default_sidebar_width = config.ui.sidebar_width; - if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault { - self.state.sidebar_width = config.ui.sidebar_width; - } - self.state.sidebar_min_width = config.ui.sidebar_min_width; - self.state.sidebar_max_width = config.ui.sidebar_max_width; - self.state.sidebar_collapsed_mode = config.ui.sidebar_collapsed_mode; - self.state.mobile_width_threshold = config.ui.mobile_width_threshold; - // Re-clamp the live width to the new bounds. No source guard — bounds - // always apply, including to widths owned by Persisted or Manual. - self.state.sidebar_width = self - .state - .sidebar_width - .clamp(self.state.sidebar_min_width, self.state.sidebar_max_width); - self.state.mouse_capture = config.ui.mouse_capture; - self.state.copy_on_select = config.ui.copy_on_select; - self.state.redraw_on_focus_gained = config.ui.redraw_on_focus_gained; self.loaded_host_cursor = config.ui.host_cursor; - self.state.mouse_scroll_lines = config.ui.mouse_scroll_lines(); - self.state.right_click_passthrough_modifiers = - config.ui.right_click_passthrough_modifiers(); self.state.confirm_close = config.ui.confirm_close; - self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name; - self.state.prompt_new_workspace_name = config.ui.prompt_new_workspace_name; self.state.pane_borders = config.ui.pane_borders; self.state.pane_outer_borders = config.ui.pane_outer_borders; self.state.pane_scrollbars = config.ui.pane_scrollbars; self.state.pane_gaps = config.ui.pane_gaps; self.state.show_agent_labels_on_pane_borders = config.ui.show_agent_labels_on_pane_borders; - self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab; - self.state.tab_bar_position = config.ui.tab_bar_position; self.configure_tab_bar_status( &config.ui.tab_bar_right, &config.ui.tab_bar_right_separator, @@ -1187,11 +830,8 @@ impl App { self.configure_window_title(&config.ui.window_title); self.state.agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); - self.state.status_indicators = config.ui.status_indicators; self.state.sidebar_agents = config.ui.sidebar.agents.clone(); self.state.sidebar_spaces = config.ui.sidebar.spaces.clone(); - self.state.agent_panel_scroll = 0; - self.state.accent = crate::config::parse_color(&config.ui.accent); self.state.sound = config.ui.sound.clone(); self.state.toast_config = config.ui.toast.clone(); } @@ -1212,10 +852,7 @@ impl App { self.state.cjk_ime_agents = parse_cjk_ime_agents(&config.experimental.cjk_ime_agents); self.state.cjk_ime_cursor_shape = config.experimental.cjk_ime_cursor_shape.to_decscusr(); - self.state.switch_ascii_input_source_in_prefix = - config.experimental.switch_ascii_input_source_in_prefix; self.persist_pane_history = config.experimental.pane_history; - self.state.pane_history_persistence = config.experimental.pane_history; if !self.persist_pane_history { crate::persist::clear_history(); } @@ -1244,7 +881,7 @@ impl App { self.next_auto_update_check = None; } else if !previous_version_check_enabled && background_update_check_enabled( - self.no_session, + self.policy.background_updates, self.update_version_check_enabled, ) && self.state.update_available.is_none() @@ -1256,7 +893,7 @@ impl App { self.next_agent_manifest_update_check = None; } else if !previous_manifest_check_enabled && background_update_check_enabled( - self.no_session, + self.policy.background_updates, self.update_manifest_check_enabled, ) { @@ -1319,438 +956,20 @@ impl App { } } } - -// --------------------------------------------------------------------------- -// Input routing for headless server mode -// --------------------------------------------------------------------------- - -impl App { - pub(crate) fn terminal_input_context(&self) -> Option { - if let Some(popup) = &self.state.popup_pane { - Some(TerminalInputContext::Popup(popup.terminal_id.clone())) - } else if self.state.mode == Mode::Terminal { - Some(TerminalInputContext::Pane) - } else { - None - } - } - - fn execute_repeat_plan_headless( - &mut self, - source_id: InputSourceId, - lease_key: input::InputLeaseKey, - key: crate::input::TerminalKey, - plan: input::RepeatPlan, - ) { - match plan { - input::RepeatPlan::Forwarded(target) => { - if !self.forward_terminal_key_to_target_headless(&target, key) { - self.input_leases.remove(&lease_key); - } - } - input::RepeatPlan::Reprocess { - context, - repetitions, - tracked, - } => { - let key = key - .with_kind(crossterm::event::KeyEventKind::Repeat) - .with_repeat_count(1); - let mut forwarded_target = None; - for _ in 0..repetitions { - if let Some(target) = &forwarded_target { - if !self.forward_terminal_key_to_target_headless(target, key.clone()) { - self.input_leases.remove(&lease_key); - break; - } - continue; - } - let current_context = self.terminal_input_context(); - if !self.input_leases.reprocess_allowed( - lease_key, - &context, - current_context.as_ref(), - tracked, - ) { - break; - } - if let Some(target) = - self.handle_terminal_key_headless_from(source_id, key.clone()) - { - if tracked { - self.input_leases.insert_forwarded( - lease_key, - target.clone(), - key.clone(), - ); - forwarded_target = Some(target); - } - } - } - } - input::RepeatPlan::Ignore => {} - } - } - - /// Routes raw input bytes from a client through the existing input pipeline. - /// - /// The input bytes are parsed into `RawInputEvent`s and then processed. - /// In terminal mode, keys are routed through the same semantic - /// endpoint key-handling path so they are re-encoded for the - /// focused pane's negotiated keyboard protocol instead of passing host - /// terminal escape sequences through unchanged. - #[cfg(test)] - pub(crate) fn route_client_input(&mut self, data: Vec) { - let events = crate::raw_input::parse_raw_input_bytes_sync(&data); - self.route_client_events(events, true); - } - - pub(crate) fn route_client_pixel_mouse( - &mut self, - source_id: InputSourceId, - data: &[u8], - geometry: crate::input::mouse::HostGeometry, - ) -> bool { - let Some((x, y)) = crate::input::mouse::parse_report(data) else { - return false; - }; - let Some((column, row)) = geometry.cell(x, y) else { - return false; - }; - let Some(cell_report) = crate::input::mouse::report_at_cell(data, column, row) else { - return false; - }; - let mut events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); - if events.len() != 1 || !matches!(events[0], crate::raw_input::RawInputEvent::Mouse(_)) { - return false; - } - self.state.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry }); - self.route_client_events_from(source_id, std::mem::take(&mut events), false); - self.state.host_mouse_pixels = None; - true - } - - pub(crate) fn route_client_events( - &mut self, - events: Vec, - apply_host_terminal_theme: bool, - ) { - self.route_client_events_from(LOCAL_INPUT_SOURCE, events, apply_host_terminal_theme); - } - - pub(crate) fn route_client_events_from( - &mut self, - source_id: InputSourceId, - events: Vec, - apply_host_terminal_theme: bool, - ) { - for event in events { - let previous_mode = self.state.mode; - match event { - crate::raw_input::RawInputEvent::Key(key) => { - let lease_key = input::InputLeaseKey::new(source_id, &key); - let key = self.input_leases.normalize_press(&lease_key, key); - match key.kind { - crossterm::event::KeyEventKind::Press => { - let initial_context = self.terminal_input_context(); - let target = if initial_context.is_some() { - self.handle_terminal_key_headless_from(source_id, key.clone()) - } else { - self.handle_non_terminal_key_headless(key.clone()); - None - }; - let resulting_context = self.terminal_input_context(); - let plan = self.input_leases.complete_press( - lease_key, - &key, - initial_context.as_ref(), - resulting_context.as_ref(), - target, - ); - self.execute_repeat_plan_headless(source_id, lease_key, key, plan); - } - crossterm::event::KeyEventKind::Repeat => { - let current_context = self.terminal_input_context(); - let plan = self.input_leases.plan_repeat( - lease_key, - &key, - current_context.as_ref(), - ); - self.execute_repeat_plan_headless(source_id, lease_key, key, plan); - } - crossterm::event::KeyEventKind::Release => { - if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { - let _ = self - .forward_terminal_key_to_target_headless(&lease.target, key); - } - } - } - } - crate::raw_input::RawInputEvent::Text(text) => { - self.handle_text_commit_headless(text.as_str()); - } - crate::raw_input::RawInputEvent::Mouse(mouse) => { - if self.state.popup_pane.is_some() || self.state.mouse_capture { - self.handle_mouse_from_input_source(source_id, mouse); - } else { - self.state - .handle_pane_mouse_only(&self.terminal_runtimes, mouse); - } - } - crate::raw_input::RawInputEvent::Paste(text) => { - if self.try_route_paste_to_popup(&text) { - } else if self.state.mode != Mode::Terminal { - self.paste_into_active_text_input(&text); - } else { - if let Some(ws_idx) = self.state.active { - if let Some(ws) = self.state.workspaces.get(ws_idx) { - if let Some(focused) = ws.focused_pane_id() { - if let Some(runtime) = self.state.runtime_for_pane_in_workspace( - &self.terminal_runtimes, - ws_idx, - focused, - ) { - let _ = runtime.try_send_paste(text); - } - } - } - } - } - } - crate::raw_input::RawInputEvent::OuterFocusGained => { - self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained); - } - crate::raw_input::RawInputEvent::OuterFocusLost => { - self.release_input_source_headless(source_id); - self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost); - } - crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { - if apply_host_terminal_theme { - self.update_host_terminal_theme(kind, color); - } - } - crate::raw_input::RawInputEvent::HostPaletteColors { colors } => { - if apply_host_terminal_theme { - self.update_host_terminal_palette_colors(&colors); - } - } - crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { - if apply_host_terminal_theme { - self.set_host_terminal_appearance(appearance, true); - } - } - crate::raw_input::RawInputEvent::HostCellSizeReport { .. } => {} - crate::raw_input::RawInputEvent::Unsupported => {} - } - self.sync_prefix_input_source(previous_mode); - } - } - - pub(crate) fn clear_input_source(&mut self, source_id: InputSourceId) { - // Call this only when the input source is gone for good. A pending URL - // click has to outlive a plain focus change, because opening the URL - // raises the browser and costs the host terminal its focus before the - // mouse release arrives. - self.pending_url_click_sources.remove(&source_id); - self.release_input_source_headless(source_id); - } - - /// Handles a key event in non-terminal mode for the headless server. - /// - /// Uses the standalone handler functions that work on `&mut AppState` - /// without requiring an async input loop. - fn handle_non_terminal_key_headless(&mut self, key: crate::input::TerminalKey) { - let key_event = key.as_key_event(); - if input::modal_paste_target_active(&self.state) - && input::is_modal_paste_shortcut(&key_event) - { - if let Some(text) = crate::platform::read_clipboard_text() { - self.paste_into_active_text_input(&text); - } - return; - } - - match self.state.mode { - Mode::Prefix => { - self.handle_prefix_key(key); - } - Mode::Navigate => { - self.handle_navigate_key(key); - } - Mode::Copy => { - self.handle_copy_mode_key(key); - } - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { - self.handle_rename_key_via_api(key_event); - } - Mode::NewLinkedWorktree => { - self.handle_worktree_create_key(key_event); - } - Mode::OpenExistingWorktree => { - self.handle_worktree_open_key(key_event); - } - Mode::ConfirmRemoveWorktree => { - self.handle_worktree_remove_key(key_event); - } - Mode::Resize => { - self.handle_resize_key_via_api(key); - } - Mode::ConfirmClose => { - self.handle_confirm_close_key_via_api(key_event); - } - Mode::ContextMenu => { - self.handle_context_menu_key_via_api(key_event); - } - Mode::KeybindHelp => { - input::handle_keybind_help_key(&mut self.state, key); - } - Mode::GlobalMenu => { - input::handle_global_menu_key(&mut self.state, key_event); - } - Mode::Onboarding => { - self.handle_onboarding_key(key_event); - } - Mode::ReleaseNotes => { - self.handle_release_notes_key(key_event); - } - Mode::ProductAnnouncement => { - self.handle_product_announcement_key(key_event); - } - Mode::Settings => { - self.handle_settings_key(key_event); - } - Mode::Navigator => { - input::handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event); - } - Mode::Terminal => { - // Should not be called in terminal mode. - } - } - } -} - #[cfg(test)] mod tests { use super::*; use crate::config::Config; use crate::detect::{Agent, AgentState}; - use crate::terminal::TerminalRuntime; use crate::workspace::Workspace; - use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::sync::Mutex; - fn raw_key( - code: KeyCode, - modifiers: KeyModifiers, - kind: KeyEventKind, - ) -> crate::raw_input::RawInputEvent { - crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new(code, modifiers).with_kind(kind), - ) - } - - #[cfg(windows)] - #[tokio::test] - async fn native_repeats_and_releases_follow_the_pressed_pane() { - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 27, - virtual_scan_code: 1, - unicode: 27, - control_key_state: 0, - }; - let key = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) - .with_windows_record(record); - let enhanced = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) - .with_windows_record(crate::input::WindowsKeyRecord { - control_key_state: 0x0100, - ..record - }); - assert_ne!( - input::InputLeaseKey::new(7, &key), - input::InputLeaseKey::new(7, &enhanced) - ); - - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let pressed_pane = workspace.focused_pane_id().unwrap(); - let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(pressed_pane); - let (pressed_runtime, mut pressed_rx) = - TerminalRuntime::test_with_channel_capacity(80, 24, 6); - let (other_runtime, mut other_rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 6); - workspace.tabs[0] - .runtimes - .insert(pressed_pane, pressed_runtime); - workspace.tabs[0].runtimes.insert(other_pane, other_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let release = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) - .with_windows_record(crate::input::WindowsKeyRecord { - key_down: false, - repeat_count: 1, - unicode: 0, - ..record - }) - .with_kind(KeyEventKind::Release); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(key.clone())], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, other_pane)); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key(key.clone()), - crate::raw_input::RawInputEvent::Key(key.clone().with_kind(KeyEventKind::Repeat)), - crate::raw_input::RawInputEvent::Key(release), - crate::raw_input::RawInputEvent::Key(key.clone()), - crate::raw_input::RawInputEvent::OuterFocusLost, - ], - false, - ); - - for _ in 0..3 { - assert_eq!( - pressed_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") - ); - } - assert_eq!( - pressed_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;0;0;0;1_") - ); - assert!(pressed_rx.try_recv().is_err()); - - assert_eq!( - other_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") - ); - assert_eq!( - other_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;27;0;0;1_") - ); - assert!(other_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - fn release_notes_state() -> state::ReleaseNotesState { - state::ReleaseNotesState { - version: "0.1.0".into(), - body: "notes".into(), - scroll: 0, - preview: true, - } - } - fn test_app() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1775,163 +994,6 @@ mod tests { "/usr/bin/true" } - /// Drain the app event channel, returning the `active` flags of any emitted - /// `PrefixInputSource` events (the host-local input-source intents). - fn drained_prefix_active(app: &mut App) -> Vec { - let mut out = Vec::new(); - while let Ok(ev) = app.event_rx.try_recv() { - if let crate::events::AppEvent::PrefixInputSource { active } = ev { - out.push(active); - } - } - out - } - - #[test] - fn sync_prefix_input_source_emits_switch_then_restore_when_enabled() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - - // Terminal -> Prefix emits the ASCII-switch intent. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Prefix -> Terminal emits the restore intent. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn sync_prefix_input_source_does_not_emit_switch_when_flag_disabled() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = false; - - // Entering the realm with the flag off emits nothing. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert!(drained_prefix_active(&mut app).is_empty()); - - // Leaving the realm still emits the restore (harmless if nothing was switched), so a - // mid-interaction flag toggle can't strand the host on ASCII. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn mode_wants_ascii_input_classification() { - // Allowlist: the prefix command/navigation realm wants ASCII. - for mode in [ - Mode::Prefix, - Mode::Navigate, - Mode::Navigator, - Mode::Copy, - Mode::Resize, - Mode::ConfirmClose, - Mode::ConfirmRemoveWorktree, - Mode::ContextMenu, - Mode::GlobalMenu, - Mode::KeybindHelp, - ] { - assert!(mode.wants_ascii_input(), "{mode:?} should want ASCII"); - } - // Everything else (terminal, text entry, startup overlays) keeps the user's IME. - for mode in [ - Mode::Terminal, - Mode::RenameWorkspace, - Mode::RenameTab, - Mode::RenamePane, - Mode::NewLinkedWorktree, - Mode::OpenExistingWorktree, - Mode::Settings, - Mode::Onboarding, - Mode::ReleaseNotes, - Mode::ProductAnnouncement, - ] { - assert!(!mode.wants_ascii_input(), "{mode:?} should keep the IME"); - } - } - - #[test] - fn sync_prefix_input_source_keeps_realm_across_multi_level_prefix_commands() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - - // Terminal -> Prefix switches once. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Prefix -> sub-mode and sub-mode -> sub-mode stay in the realm: no emit. - app.state.mode = Mode::Navigator; - app.sync_prefix_input_source(Mode::Prefix); - app.state.mode = Mode::Resize; - app.sync_prefix_input_source(Mode::Navigator); - assert!( - drained_prefix_active(&mut app).is_empty(), - "must not switch or restore while still in the realm" - ); - - // Leaving the realm back to the terminal restores. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Resize); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn sync_prefix_input_source_restores_when_entering_rename_text_mode() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Prefix -> RenameTab leaves the realm (text entry wants the IME): restore. - app.state.mode = Mode::RenameTab; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn client_input_dispatch_emits_input_source_intent_when_leaving_prefix() { - // Leaving prefix mode happens inside the raw-input dispatch, not in `handle_key` itself — - // the sync must sit at the dispatch layer so any event that exits prefix (here Esc) still - // emits the restore intent. - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - // ctrl+b (the default prefix key) enters prefix mode → switch intent. - app.route_client_events( - vec![raw_key( - KeyCode::Char('b'), - KeyModifiers::CONTROL, - KeyEventKind::Press, - )], - true, - ); - assert_eq!(app.state.mode, Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Esc leaves prefix mode → restore intent. - app.route_client_events( - vec![raw_key( - KeyCode::Esc, - KeyModifiers::empty(), - KeyEventKind::Press, - )], - true, - ); - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - fn config_env_lock() -> &'static Mutex<()> { crate::config::test_config_env_lock() } @@ -1970,7 +1032,7 @@ mod tests { let mut app = test_app(); app.git_refresh_in_flight = true; - let changed = app.handle_internal_event_with_prefix_sync(AppEvent::GitStatusRefreshed { + let changed = app.handle_internal_event_with_render_impact(AppEvent::GitStatusRefreshed { results: Vec::new(), cache_updates: Vec::new(), }); @@ -2001,10 +1063,10 @@ mod tests { result: Ok(output.map(str::to_string)), }; - assert!(!app.handle_internal_event_with_prefix_sync(event(generation, None))); - assert!(app.handle_internal_event_with_prefix_sync(event(generation, Some("ready")))); - assert!(!app.handle_internal_event_with_prefix_sync(event(generation, Some("ready")))); - assert!(!app.handle_internal_event_with_prefix_sync(event( + assert!(!app.handle_internal_event_with_render_impact(event(generation, None))); + assert!(app.handle_internal_event_with_render_impact(event(generation, Some("ready")))); + assert!(!app.handle_internal_event_with_render_impact(event(generation, Some("ready")))); + assert!(!app.handle_internal_event_with_render_impact(event( generation.wrapping_add(1), Some("stale"), ))); @@ -2051,53 +1113,6 @@ mod tests { assert!(app.render_dirty.is_pending()); } - #[test] - fn clipboard_write_event_shows_feedback_toast() { - let mut app = test_app(); - - app.show_clipboard_feedback(); - - assert!(app.state.toast.is_none()); - let feedback = app.state.copy_feedback.as_ref().expect("copy feedback"); - assert_eq!(feedback.message, "copied to clipboard"); - assert!(app.copy_feedback_deadline.is_some()); - } - - #[test] - fn clipboard_feedback_can_be_disabled() { - let mut app = test_app(); - app.state.toast_config.clipboard.enabled = false; - - app.show_clipboard_feedback(); - - assert!(app.state.copy_feedback.is_none()); - assert!(app.copy_feedback_deadline.is_none()); - } - - #[test] - fn clipboard_feedback_does_not_replace_notification_toast() { - let mut app = test_app(); - app.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "pi needs attention".to_string(), - context: "background · 2".to_string(), - position: None, - target: None, - }); - let original_toast = app.state.toast.clone(); - - app.show_clipboard_feedback(); - - assert_eq!(app.state.toast, original_toast); - assert_eq!( - app.state - .copy_feedback - .as_ref() - .map(|feedback| feedback.message.as_str()), - Some("copied to clipboard") - ); - } - #[test] fn notification_show_api_creates_herdr_toast_with_position() { let mut app = test_app(); @@ -2304,61 +1319,17 @@ mod tests { config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); } - #[test] - fn startup_uses_configured_sidebar_state() { - let mut config = Config::default(); - config.ui.sidebar_start_collapsed = true; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert!(app.state.sidebar_collapsed); - } - - #[test] - fn startup_uses_redraw_on_focus_gained_config() { - let mut config = Config::default(); - config.ui.redraw_on_focus_gained = false; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert!(!app.state.redraw_on_focus_gained); - } - - #[test] - fn workspace_name_prompt_suppresses_default_creation_while_pending() { - let mut app = test_app(); - app.state.prompt_new_workspace_name = true; - - app.begin_tui_workspace_create("test.workspace.create"); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert!(app.state.pending_workspace_create_cwd.is_some()); - assert!(!app.ensure_default_workspace()); - assert!(app.state.workspaces.is_empty()); - - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); - assert!(app.state.workspaces.is_empty()); - assert!(app.state.pending_workspace_create_cwd.is_none()); - } - - #[test] - fn startup_uses_workspace_name_prompt_config() { - let mut config = Config::default(); - config.ui.prompt_new_workspace_name = true; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert!(app.state.prompt_new_workspace_name); - } - #[test] fn theme_auto_switch_is_opt_in_and_preserves_manual_default() { let mut config = Config::default(); @@ -2376,7 +1347,13 @@ mod tests { }); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert!(!app.state.theme_runtime.auto_switch); assert_eq!(app.state.theme_name, "tokyo-night"); @@ -2389,12 +1366,19 @@ mod tests { config.theme.name = Some("tokyo-night".to_string()); config.theme.auto_switch = true; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!(app.state.theme_name, "tokyo-night"); - assert!( - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true) - ); + assert!(app.set_host_terminal_appearance_state( + Some(crate::terminal_theme::HostAppearance::Light), + true, + )); assert_eq!(app.state.theme_name, "tokyo-night-day"); assert_eq!(app.state.palette, state::Palette::tokyo_night_day()); @@ -2410,9 +1394,18 @@ mod tests { ..Default::default() }); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true); + app.set_host_terminal_appearance_state( + Some(crate::terminal_theme::HostAppearance::Light), + true, + ); assert_eq!(app.state.theme_name, "gruvbox-light"); assert_eq!( @@ -2442,7 +1435,13 @@ mod tests { ..Default::default() }); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!( app.state.palette.accent, @@ -2461,7 +1460,10 @@ mod tests { ratatui::style::Color::Rgb(16, 17, 18) ); - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true); + app.set_host_terminal_appearance_state( + Some(crate::terminal_theme::HostAppearance::Light), + true, + ); assert_eq!( app.state.palette.accent, @@ -2470,31 +1472,6 @@ mod tests { assert_eq!(app.state.palette.text, ratatui::style::Color::Rgb(4, 5, 6)); } - #[test] - fn inferred_background_appearance_does_not_override_explicit_report() { - let mut config = Config::default(); - config.theme.name = Some("catppuccin".to_string()); - config.theme.auto_switch = true; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Dark, true); - app.update_host_terminal_theme( - crate::terminal_theme::DefaultColorKind::Background, - crate::terminal_theme::RgbColor { - r: 0xff, - g: 0xff, - b: 0xff, - }, - ); - - assert_eq!( - app.state.host_terminal_appearance, - Some(crate::terminal_theme::HostAppearance::Dark) - ); - assert_eq!(app.state.theme_name, "catppuccin"); - } - #[test] fn startup_restores_preview_update_available_from_saved_notes() { let _guard = config_env_lock().lock().unwrap(); @@ -2619,10 +1596,15 @@ mod tests { }; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.state.release_notes.is_none()); assert!(app.state.latest_release_notes_available); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -2630,7 +1612,7 @@ mod tests { } #[test] - fn startup_still_auto_opens_unseen_product_announcement() { + fn startup_loads_unseen_product_announcement_for_clients() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("startup-product-announcement-auto-open"); let state_home = path.parent().unwrap().join("state"); @@ -2656,9 +1638,15 @@ mod tests { }; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); - assert_eq!(app.state.mode, Mode::ProductAnnouncement); + assert_eq!(app.state.mode, Mode::Navigate); assert_eq!( app.state .product_announcement @@ -2666,7 +1654,6 @@ mod tests { .map(|announcement| announcement.id.as_str()), Some("startup-announcement") ); - assert!(app.state.release_notes.is_none()); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); restore_xdg_state_home(original_xdg_state_home); @@ -2680,35 +1667,12 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[server]\nheadless_cols = 160\nheadless_rows = 50\n[ui]\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\nprompt_new_workspace_name = true\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n", + "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[server]\nheadless_cols = 160\nheadless_rows = 50\n[ui]\nagent_panel_sort = \"priority\"\n[ui.toast]\ndelivery = \"herdr\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let selection_pane = crate::layout::PaneId::alloc(); - app.state.selection = Some(crate::selection::Selection::range( - selection_pane, - 0, - 0, - 1, - None, - )); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 1, - last_mouse_screen_row: 1, - inner_rect: ratatui::layout::Rect::new(0, 0, 2, 2), - }); - let selection_deadline = Instant::now(); - app.selection_autoscroll_deadline = Some(selection_deadline); - app.selection_highlight_clear_deadline = Some(selection_deadline); - app.last_pane_click = Some(PaneClickState { - pane_id: selection_pane, - viewport_row: 0, - col: 0, - at: selection_deadline, - }); app.next_auto_update_check = Some(Instant::now()); app.next_agent_manifest_update_check = Some(Instant::now()); let report = app.reload_config(); @@ -2727,33 +1691,8 @@ mod tests { crate::config::ToastDelivery::Herdr ); assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); - assert!(!app.state.redraw_on_focus_gained); - assert!(!app.state.copy_on_select); - assert!(app.state.prompt_new_workspace_name); - assert!(app.state.selection.is_some()); - assert!(app.state.selection_autoscroll.is_some()); - assert_eq!(app.selection_autoscroll_deadline, Some(selection_deadline)); - assert_eq!( - app.selection_highlight_clear_deadline, - Some(selection_deadline) - ); - assert!(app.last_pane_click.is_some()); - - app.state.mode = Mode::Copy; - app.state.selection = Some(crate::selection::Selection::range( - selection_pane, - 0, - 0, - 1, - None, - )); let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert!(app.state.selection.is_some()); - assert_eq!( - app.state.right_click_passthrough_modifiers, - Some(KeyModifiers::CONTROL) - ); assert!(app.state.request_client_config_reload); assert_eq!(app.state.default_shell, "nu"); assert_eq!( @@ -2768,7 +1707,6 @@ mod tests { assert!(!app.update_manifest_check_enabled); assert!(app.next_auto_update_check.is_none()); assert!(app.next_agent_manifest_update_check.is_none()); - assert!(app.state.switch_ascii_input_source_in_prefix); assert!(app.state.config_diagnostic.is_none()); let toast = app.state.toast.as_ref().unwrap(); assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled); @@ -2823,37 +1761,6 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - #[test] - fn reload_config_updates_sidebar_width_only_when_config_owned() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-width"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.sidebar_width_source, - state::SidebarWidthSource::ConfigDefault - ); - - std::fs::write(&path, "[ui]\nsidebar_width = 34\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.default_sidebar_width, 34); - assert_eq!(app.state.sidebar_width, 34); - - app.state.sidebar_width = 31; - app.state.sidebar_width_source = state::SidebarWidthSource::Manual; - std::fs::write(&path, "[ui]\nsidebar_width = 35\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.default_sidebar_width, 35); - assert_eq!(app.state.sidebar_width, 31); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - #[test] fn reload_config_updates_sidebar_token_rows() { let _guard = config_env_lock().lock().unwrap(); @@ -2867,11 +1774,9 @@ mod tests { "[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\nrow_gap = 1\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\nrow_gap = 3\n", ) .unwrap(); - app.state.agent_panel_scroll = 5; let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.agent_panel_scroll, 0); assert_eq!( app.state.sidebar_agents.rows, vec![vec![ @@ -2909,146 +1814,6 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - #[test] - fn reload_config_does_not_reset_sidebar_to_startup_state() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-start-collapsed"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert!(!app.state.sidebar_collapsed); - - std::fs::write(&path, "[ui]\nsidebar_start_collapsed = true\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert!(!app.state.sidebar_collapsed); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn reload_config_updates_sidebar_collapsed_mode() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-collapsed-mode"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.sidebar_collapsed_mode, - crate::config::SidebarCollapsedModeConfig::Compact - ); - - std::fs::write(&path, "[ui]\nsidebar_collapsed_mode = \"hidden\"\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!( - app.state.sidebar_collapsed_mode, - crate::config::SidebarCollapsedModeConfig::Hidden - ); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn reload_config_updates_sidebar_bounds_and_reclamps() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-bounds"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - // Default bounds. - assert_eq!(app.state.sidebar_min_width, 18); - assert_eq!(app.state.sidebar_max_width, 36); - assert_eq!( - app.state.mobile_width_threshold, - crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD - ); - - // Manually set a width and flip the source so the existing - // sidebar_width-only-when-config-owned guard does NOT update it. - app.state.sidebar_width = 30; - app.state.sidebar_width_source = state::SidebarWidthSource::Manual; - - // Tightening max below the current width must re-clamp the live width - // even when source is Manual — bounds always apply. - std::fs::write(&path, "[ui]\nsidebar_max_width = 24\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.sidebar_max_width, 24); - assert_eq!( - app.state.sidebar_width, 24, - "manual width must re-clamp to new max" - ); - - // Loosening max leaves the live width alone (it's already within bounds). - app.state.sidebar_width = 24; - std::fs::write(&path, "[ui]\nsidebar_max_width = 60\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.sidebar_max_width, 60); - assert_eq!(app.state.sidebar_width, 24); - - // Raising min above the current width re-clamps upward. - std::fs::write(&path, "[ui]\nsidebar_min_width = 30\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.sidebar_min_width, 30); - assert_eq!( - app.state.sidebar_width, 30, - "manual width must re-clamp up to new min" - ); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn reload_config_updates_mobile_width_threshold() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-mobile-width-threshold"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.mobile_width_threshold, - crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD - ); - - std::fs::write(&path, "[ui]\nmobile_width_threshold = 96\n").unwrap(); - let report = app.reload_config(); - - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.mobile_width_threshold, 96); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn app_new_falls_back_to_default_bounds_on_inverted_config() { - let mut config = Config::default(); - config.ui.sidebar_min_width = 50; - config.ui.sidebar_max_width = 30; - - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert_eq!( - app.state.sidebar_min_width, 18, - "App::new must fall back to default min when bounds are inverted" - ); - assert_eq!( - app.state.sidebar_max_width, 36, - "App::new must fall back to default max when bounds are inverted" - ); - } - #[test] fn reload_config_invalid_sidebar_bounds_keeps_previous_ui_and_returns_partial() { let _guard = config_env_lock().lock().unwrap(); @@ -3057,17 +1822,15 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let original_min = app.state.sidebar_min_width; - let original_max = app.state.sidebar_max_width; - let original_mouse_capture = app.state.mouse_capture; + let original_pane_borders = app.state.pane_borders; // Pair the bad bounds with another `[ui]` field change to confirm the // entire section is treated as invalid (not just the bounds). - let target_mouse_capture = !original_mouse_capture; + let target_pane_borders = !original_pane_borders; std::fs::write( &path, format!( - "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\nmouse_capture = {}\n", - target_mouse_capture + "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\npane_borders = {}\n", + target_pane_borders ), ) .unwrap(); @@ -3079,11 +1842,9 @@ mod tests { && diagnostic.contains("sidebar_max_width") && diagnostic.contains("greater") })); - assert_eq!(app.state.sidebar_min_width, original_min); - assert_eq!(app.state.sidebar_max_width, original_max); assert_eq!( - app.state.mouse_capture, original_mouse_capture, - "[ui] is treated as invalid on bad bounds; mouse_capture must not apply" + app.state.pane_borders, original_pane_borders, + "[ui] is treated as invalid on bad bounds; pane_borders must not apply" ); assert_eq!( app.state.config_diagnostic.as_deref(), @@ -3135,10 +1896,10 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let target_mouse_capture = !app.state.mouse_capture; + let target_pane_borders = !app.state.pane_borders; std::fs::write( &path, - format!("[ui]\nmouse_capture = {target_mouse_capture}\nmouse_captur = false\n"), + format!("[ui]\npane_borders = {target_pane_borders}\nmouse_captur = false\n"), ) .unwrap(); @@ -3149,7 +1910,7 @@ mod tests { report.diagnostics, vec!["unknown config key ui.mouse_captur; ignoring key"] ); - assert_eq!(app.state.mouse_capture, target_mouse_capture); + assert_eq!(app.state.pane_borders, target_pane_borders); assert_eq!( app.state.config_diagnostic.as_deref(), Some("config.toml has unknown keys; herdr config check") @@ -3256,85 +2017,6 @@ mod tests { std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - - #[test] - fn settings_save_toast_delivery_persists_then_applies_live_config() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("settings-save-toast-delivery"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "onboarding = false\n").unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.toast_config.delivery, - crate::config::ToastDelivery::Off - ); - - app.save_toast_delivery(crate::config::ToastDelivery::Terminal); - - assert_eq!( - app.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal - ); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("delivery = \"terminal\"")); - assert!(app.state.config_diagnostic.is_none()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn save_status_indicators_persists_then_applies_live_config() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("save-status-indicators"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "onboarding = false\n").unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.status_indicators, - crate::config::StatusIndicatorStyle::Dots - ); - - app.save_status_indicators(crate::config::StatusIndicatorStyle::Symbols); - - assert_eq!( - app.state.status_indicators, - crate::config::StatusIndicatorStyle::Symbols - ); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("status_indicators = \"symbols\"")); - assert!(app.state.config_diagnostic.is_none()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn save_agent_panel_sort_persists_then_applies_live_config() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("save-agent-panel-sort"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "onboarding = false\n").unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Spaces); - - app.save_agent_panel_sort(state::AgentPanelSort::Priority); - - assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("agent_panel_sort = \"priority\"")); - assert!(app.state.config_diagnostic.is_none()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - #[test] fn reload_config_keeps_current_state_on_invalid_toml() { let _guard = config_env_lock().lock().unwrap(); @@ -3368,97 +2050,6 @@ mod tests { std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - - #[tokio::test] - async fn client_input_forwards_report_all_printable_event_kinds() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events( - [ - KeyEventKind::Press, - KeyEventKind::Repeat, - KeyEventKind::Release, - ] - .into_iter() - .map(|kind| raw_key(KeyCode::Char('j'), KeyModifiers::empty(), kind)) - .collect(), - true, - ); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:2u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn repeat_key_events_are_ignored_outside_terminal_mode() { - let mut app = test_app(); - app.state.mode = Mode::ReleaseNotes; - app.state.release_notes = Some(release_notes_state()); - - app.route_client_events( - vec![raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Repeat, - )], - true, - ); - - assert_eq!(app.state.mode, Mode::ReleaseNotes); - assert!(app.state.release_notes.is_some()); - } - - #[test] - fn modal_press_does_not_leak_repeat_into_terminal_mode() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::ReleaseNotes; - app.state.release_notes = Some(release_notes_state()); - - app.route_client_events( - vec![raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Press, - )], - true, - ); - assert_eq!(app.state.mode, Mode::Terminal); - - app.route_client_events( - vec![ - raw_key(KeyCode::Enter, KeyModifiers::empty(), KeyEventKind::Repeat), - raw_key(KeyCode::Enter, KeyModifiers::empty(), KeyEventKind::Release), - raw_key(KeyCode::Enter, KeyModifiers::empty(), KeyEventKind::Press), - ], - true, - ); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.release_notes.is_none()); - } - #[test] fn read_only_api_requests_do_not_force_rerender() { let read_only = crate::api::schema::Request { @@ -3533,6 +2124,7 @@ mod tests { workspace_id: Some("w1".into()), tab_id: Some("w1:t1".into()), pane_id: Some("w1:p1".into()), + selection: None, }, ), }; @@ -3976,105 +2568,6 @@ mod tests { })); } - #[tokio::test] - async fn pane_split_request_targets_pane_in_background_tab() { - let _guard = config_env_lock().lock().unwrap(); - let original_shell = std::env::var_os("SHELL"); - std::env::set_var("SHELL", exiting_test_command()); - - let mut app = test_app(); - let mut workspace = Workspace::test_new("api-pane-split-background-tab"); - let active_pane = workspace.tabs[0].root_pane; - let background_tab = workspace.test_add_tab(Some("worker")); - let target_pane = workspace.tabs[background_tab].root_pane; - workspace.switch_tab(background_tab); - let background_previous_focus = - workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.switch_tab(0); - app.state.workspaces = vec![workspace]; - app.state.ensure_test_terminals(); - let split_cwd = std::env::temp_dir(); - let target_terminal_id = app.state.workspaces[0] - .pane_state(target_pane) - .unwrap() - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&target_terminal_id) - .unwrap() - .cwd = split_cwd.clone(); - app.state.active = Some(0); - app.state.selected = 0; - app.state - .focus_pane_in_workspace(0, background_previous_focus); - app.state.focus_pane_in_workspace(0, active_pane); - - let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id; - let target_tab_id = app.public_tab_id(0, background_tab).unwrap(); - - let response = app.handle_api_request(crate::api::schema::Request { - id: "req_pane_split_background_tab".into(), - method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams { - workspace_id: None, - target_pane_id: Some(target_pane_id), - direction: crate::api::schema::SplitDirection::Right, - ratio: None, - cwd: None, - focus: false, - right_click: Default::default(), - env: Default::default(), - }), - }); - let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - - assert_eq!(response["result"]["type"], "pane_info"); - assert_eq!(response["result"]["pane"]["tab_id"], target_tab_id); - let response_cwd = - std::path::PathBuf::from(response["result"]["pane"]["cwd"].as_str().unwrap()); - assert_eq!( - crate::worktree::canonical_or_original(&response_cwd), - crate::worktree::canonical_or_original(&split_cwd) - ); - assert_eq!(response["result"]["pane"]["focused"], false); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].active_tab, 0); - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - active_pane - ); - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert_eq!( - app.state.workspaces[0].tabs[background_tab] - .layout - .focused(), - background_previous_focus - ); - assert_eq!( - app.state.workspaces[0].tabs[background_tab] - .layout - .pane_count(), - 3 - ); - app.state.last_pane(); - assert_eq!(app.state.workspaces[0].active_tab, background_tab); - assert_eq!( - app.state.workspaces[0].tabs[background_tab] - .layout - .focused(), - background_previous_focus - ); - - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } - match original_shell { - Some(value) => std::env::set_var("SHELL", value), - None => std::env::remove_var("SHELL"), - } - } - #[tokio::test] async fn pane_split_request_focuses_new_pane_when_requested() { let _guard = config_env_lock().lock().unwrap(); @@ -4416,15 +2909,14 @@ mod tests { let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["error"]["code"], "confirmation_required"); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.selected, 0); + assert_eq!(app.state.selected, 1); assert_eq!(app.state.workspaces.len(), 2); } #[test] fn session_dirty_flag_schedules_debounced_save() { let mut app = test_app(); - app.no_session = false; + app.policy.persist_session = true; app.state.session_dirty = true; app.sync_session_save_schedule(); @@ -4470,7 +2962,7 @@ mod tests { std::env::remove_var(crate::session::SESSION_ENV_VAR); let mut app = test_app(); - app.no_session = false; + app.policy.persist_session = true; app.state.workspaces = vec![Workspace::test_new("autosave")]; app.state.ensure_test_terminals(); app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1)); @@ -4489,7 +2981,7 @@ mod tests { #[test] fn background_session_save_reschedules_when_writer_is_busy() { let mut app = test_app(); - app.no_session = false; + app.policy.persist_session = true; let (release_tx, release_rx) = std::sync::mpsc::channel(); app.session_save_thread = Some(std::thread::spawn(move || { let _ = release_rx.recv(); @@ -4501,14 +2993,14 @@ mod tests { assert!(app.session_save_deadline.is_some()); release_tx.send(()).unwrap(); - app.no_session = true; + app.policy.persist_session = false; app.save_session_now(); } #[test] fn final_session_save_joins_background_writer_before_returning() { let mut app = test_app(); - app.no_session = true; + app.policy.persist_session = false; let (release_tx, release_rx) = std::sync::mpsc::channel(); let (done_tx, done_rx) = std::sync::mpsc::channel(); app.session_save_thread = Some(std::thread::spawn(move || { @@ -4526,51 +3018,6 @@ mod tests { done_rx.try_recv().unwrap(); assert!(app.session_save_thread.is_none()); } - - #[test] - fn headless_loop_deadline_includes_selection_autoscroll_deadline() { - let mut app = test_app(); - let now = Instant::now(); - app.selection_autoscroll_deadline = Some(now + Duration::from_millis(5)); - app.session_save_deadline = Some(now + Duration::from_millis(200)); - assert_eq!( - app.next_headless_loop_deadline_with_git_refresh(now, false, true), - app.selection_autoscroll_deadline - ); - } - - #[test] - fn tick_selection_autoscroll_self_heals_when_state_cleared() { - let mut app = test_app(); - let now = Instant::now(); - app.state.selection_autoscroll = None; - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_on_rect_change() { - let mut app = test_app(); - let now = Instant::now(); - let ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - app.state.workspaces.push(ws); - app.state.active = Some(0); - app.state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); - // Set autoscroll with a stale inner_rect that doesn't match pane_infos - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 999, - inner_rect: ratatui::layout::Rect::new(0, 0, 1, 1), // wrong rect - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - #[tokio::test] async fn full_internal_event_queue_eventually_applies_working_to_idle_transition() { let mut app = test_app(); @@ -4651,1313 +3098,4 @@ mod tests { "Working→Idle should still apply after temporary queue pressure" ); } - - #[test] - fn route_client_input_dispatches_navigate_mode_keybinds() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - - // Start in navigate mode. - app.state.mode = Mode::Navigate; - - // Send Ctrl+B then Esc (prefix → leave navigate mode). - // Ctrl+B is 0x02 in raw terminal input. - // After entering navigate mode and pressing Esc, we should leave navigate mode. - let esc_bytes = vec![0x1b]; // Esc - app.route_client_input(esc_bytes); - // Esc in navigate mode should leave navigate mode. - assert_eq!( - app.state.mode, - Mode::Terminal, - "Esc should leave navigate mode and return to Terminal mode" - ); - } - - #[test] - fn explicit_text_commit_does_not_trigger_navigate_binding() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Text( - crate::input::TextCommit::new("q"), - )], - false, - ); - - assert!(!app.state.detach_requested); - assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.input_leases.is_empty()); - } - - #[test] - fn route_client_input_q_detaches_in_persistence_mode() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - - // Start in navigate mode. - app.state.mode = Mode::Navigate; - assert!(!app.state.detach_requested); - - let q_bytes = b"q".to_vec(); - app.route_client_input(q_bytes); - - assert!( - app.state.detach_requested, - "q should detach in persistence mode" - ); - assert_eq!( - app.state.mode, - Mode::Terminal, - "q should leave navigate mode" - ); - } - - #[test] - fn route_client_input_prefix_then_q_detaches_in_persistence_mode() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - - // Start in terminal mode (default after workspace creation). - app.state.mode = Mode::Terminal; - assert!(!app.state.detach_requested); - - // Send Ctrl+B (prefix key, raw byte 0x02). - let prefix_bytes = vec![0x02]; - app.route_client_input(prefix_bytes); - - assert_eq!( - app.state.mode, - Mode::Prefix, - "prefix key should enter prefix mode" - ); - assert!( - !app.state.detach_requested, - "prefix key should not set detach flag" - ); - - let q_bytes = b"q".to_vec(); - app.route_client_input(q_bytes); - - assert!( - app.state.detach_requested, - "q should detach in persistence mode" - ); - assert_eq!( - app.state.mode, - Mode::Terminal, - "q should leave navigate mode" - ); - } - - #[test] - fn route_client_input_prefix_tab_dispatches_global_last_pane() { - let config: Config = toml::from_str( - r#" -[keys] -last_pane = "prefix+tab" -"#, - ) - .unwrap(); - let mut app = test_app(); - let mut first = Workspace::test_new("one"); - let first_second_tab = first.test_add_tab(Some("logs")); - let first_second_root = first.tabs[first_second_tab].root_pane; - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - app.state.workspaces = vec![first, second]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.keybinds = config.keybinds(); - app.state.mode = Mode::Terminal; - app.state.switch_workspace_tab(0, first_second_tab); - app.state.switch_workspace_tab(1, 0); - - app.route_client_input(vec![0x02, b'\t']); - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].active_tab, first_second_tab); - assert_eq!( - app.state.workspaces[0].focused_pane_id(), - Some(first_second_root) - ); - - app.route_client_input(vec![0x02, b'\t']); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(second_root)); - } - - #[tokio::test] - async fn route_client_input_double_prefix_passes_prefix_through_to_focused_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prefix_code = KeyCode::Char('l'); - app.state.prefix_mods = KeyModifiers::CONTROL; - - app.route_client_input(vec![0x0c]); - assert_eq!(app.state.mode, Mode::Prefix); - - app.route_client_input(vec![0x0c]); - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from(vec![0x0c])); - } - - #[tokio::test] - async fn route_client_input_reencodes_terminal_keys_for_focused_pane_protocol() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - // Ghostty/kitty-style Ctrl-C should be normalized back to the pane's - // negotiated encoding instead of being forwarded verbatim. - app.route_client_input(b"\x1b[99;5u".to_vec()); - - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from(vec![3])); - - // iTerm2 and rxvt-style hosts may send F4 as CSI 14~. Normalize it - // through the same semantic key path instead of leaking host bytes. - app.route_client_input(b"\x1b[14~".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1bOS") - ); - } - - #[tokio::test] - async fn host_report_all_supplies_printable_releases_for_event_type_only_panes() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[>4;2m\x1b[=3;1u", - 4, - ); - assert_eq!( - runtime.keyboard_protocol(), - crate::input::KeyboardProtocol::Kitty { flags: 3 } - ); - assert!(runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - assert!(app.host_keyboard_report_all_requested()); - - app.route_client_input(b"\x1b[106;1:1u\x1b[106;1:2u\x1b[106;1:3u".to_vec()); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j")); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j")); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) - .unwrap(); - runtime.test_process_pty_bytes(b"\x1b[>4;0m"); - assert!(!runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)); - assert!(!app.host_keyboard_report_all_requested()); - - #[cfg(unix)] - { - runtime.test_process_pty_bytes(b"\x1b[>4;1m"); - assert!(!runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)); - assert!(!app.host_keyboard_report_all_requested()); - } - } - - #[tokio::test] - async fn host_report_all_follows_terminal_protocol_and_command_modes() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, _rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 1); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - assert!(app.host_keyboard_report_all_requested()); - - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) - .unwrap() - .test_process_pty_bytes(b"\x1b[15u"); - let other_pane = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let (other_runtime, _rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>7u", 1); - app.state.workspaces[0].tabs[0] - .runtimes - .insert(other_pane, other_runtime); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(other_pane)); - assert!(!app.host_keyboard_report_all_requested()); - - assert!(app.state.focus_pane_in_workspace(0, focused)); - app.state.mode = Mode::Prefix; - assert!(app.host_keyboard_report_all_requested()); - app.state.mode = Mode::Navigate; - assert!(app.host_keyboard_report_all_requested()); - app.state.mode = Mode::RenameWorkspace; - assert!(!app.host_keyboard_report_all_requested()); - } - - #[tokio::test] - async fn route_client_input_forwards_report_all_printable_event_kinds() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106u\x1b[106;1:2u\x1b[106;1:3u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:2u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn explicit_text_commit_bypasses_bindings_leases_and_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Text( - crate::input::TextCommit::new("你🙂"), - )], - false, - ); - - assert_eq!(rx.recv().await.unwrap().as_ref(), "你🙂".as_bytes()); - assert!(rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn committed_ime_text_bypasses_report_all_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input("你".as_bytes().to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static("你".as_bytes()) - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn kitty_associated_ime_text_bypasses_report_all_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[32;;20320:22909u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static("你好".as_bytes()) - ); - assert!(rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn committed_ascii_uppercase_bypasses_report_all_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"A".to_vec()); - - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"A")); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn committed_text_does_not_erase_owned_physical_key() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106u".to_vec()); - app.route_client_input(b"j".to_vec()); - app.route_client_input(b"\x1b[106;1:3u".to_vec()); - - assert_eq!( - rx.try_recv().expect("physical press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("committed text"), - bytes::Bytes::from_static(b"j") - ); - assert_eq!( - rx.try_recv().expect("physical release"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn outer_focus_loss_releases_owned_report_all_keys() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events( - vec![ - raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - ), - crate::raw_input::RawInputEvent::OuterFocusLost, - ], - false, - ); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("synthetic release on focus loss"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn disconnected_input_source_releases_owned_report_all_keys() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events_from( - 42, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - )], - false, - ); - app.clear_input_source(42); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("synthetic release on disconnect"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn physical_count_one_repeats_and_release_keep_the_pressed_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let pressed_pane = workspace.focused_pane_id().unwrap(); - let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(pressed_pane); - let (pressed_runtime, mut pressed_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 5); - let (other_runtime, mut other_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0] - .runtimes - .insert(pressed_pane, pressed_runtime); - workspace.tabs[0].runtimes.insert(other_pane, other_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 65, - virtual_scan_code: 30, - unicode: 97, - control_key_state: 0, - }; - let roundtrip = |events: Vec| { - let message = crate::protocol::ClientMessage::InputEvents { events }; - let encoded = - bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); - let (decoded, _): (crate::protocol::ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - let crate::protocol::ClientMessage::InputEvents { events } = decoded else { - panic!("expected structured input events"); - }; - events - .into_iter() - .map(|event| event.to_raw_input_event()) - .collect() - }; - let event = |kind, record| crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('a'), - modifiers: 0, - kind, - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::WindowsConsole { record }, - }; - - app.route_client_events( - roundtrip(vec![event(crate::protocol::ClientKeyKind::Press, record)]), - false, - ); - assert!(app.state.focus_pane_in_workspace(0, other_pane)); - app.route_client_events( - roundtrip(vec![ - event(crate::protocol::ClientKeyKind::Press, record), - event(crate::protocol::ClientKeyKind::Repeat, record), - event( - crate::protocol::ClientKeyKind::Release, - crate::input::WindowsKeyRecord { - key_down: false, - unicode: 0, - ..record - }, - ), - ]), - false, - ); - - for expected in [ - b"\x1b[97;1:1u".as_slice(), - b"\x1b[97;1:2u".as_slice(), - b"\x1b[97;1:2u".as_slice(), - b"\x1b[97;1:3u".as_slice(), - ] { - assert_eq!(pressed_rx.try_recv().unwrap().as_ref(), expected); - } - assert!(pressed_rx.try_recv().is_err()); - assert!(other_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn grouped_physical_press_and_runtime_loss_preserve_count_then_close_the_lease() { - let mut app = test_app(); - let workspace = Workspace::test_new("test"); - let pane_id = workspace.focused_pane_id().unwrap(); - let terminal_id = workspace.tabs[0].terminal_id(pane_id).unwrap().clone(); - app.state.workspaces = vec![workspace]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - app.terminal_runtimes.insert(terminal_id.clone(), runtime); - - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 3, - virtual_key_code: 65, - virtual_scan_code: 30, - unicode: 97, - control_key_state: 0, - }; - let message = crate::protocol::ClientMessage::InputEvents { - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('a'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 3, - generated_text: None, - source: crate::protocol::ClientKeySource::WindowsConsole { record }, - }], - }; - let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); - let (decoded, _): (crate::protocol::ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - let crate::protocol::ClientMessage::InputEvents { events } = decoded else { - panic!("expected structured input events"); - }; - - app.route_client_events( - events - .into_iter() - .map(|event| event.to_raw_input_event()) - .collect(), - false, - ); - app.shutdown_terminal_runtime(terminal_id.clone()); - - assert_eq!( - rx.try_recv().expect("grouped press"), - bytes::Bytes::from_static(b"\x1b[97;1:1u\x1b[97;1:2u\x1b[97;1:2u") - ); - assert_eq!( - rx.try_recv() - .expect("synthetic release before runtime shutdown"), - bytes::Bytes::from_static(b"\x1b[97;1:3u") - ); - assert!(rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - assert!(app.terminal_runtimes.get(&terminal_id).is_none()); - } - - #[tokio::test] - async fn report_all_repeat_and_release_return_to_the_pressed_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let pressed_pane = workspace.focused_pane_id().unwrap(); - let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(pressed_pane); - let (pressed_runtime, mut pressed_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - let (other_runtime, mut other_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0] - .runtimes - .insert(pressed_pane, pressed_runtime); - workspace.tabs[0].runtimes.insert(other_pane, other_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106u".to_vec()); - assert!(app.state.focus_pane_in_workspace(0, other_pane)); - app.route_client_input(b"\x1b[106;1:2u\x1b[106;1:3u".to_vec()); - - assert_eq!( - pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:2u") - ); - assert_eq!( - pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(pressed_rx.try_recv().is_err()); - assert!(other_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn report_all_key_ownership_is_isolated_by_client() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let first_pane = workspace.focused_pane_id().unwrap(); - let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(first_pane); - let (first_runtime, mut first_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - let (second_runtime, mut second_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(first_pane, first_runtime); - workspace.tabs[0] - .runtimes - .insert(second_pane, second_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events_from( - 1, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - )], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, second_pane)); - app.route_client_events_from( - 2, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - )], - false, - ); - app.route_client_events_from( - 1, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Release, - )], - false, - ); - app.route_client_events_from( - 2, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Release, - )], - false, - ); - - for rx in [&mut first_rx, &mut second_rx] { - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - } - - #[tokio::test] - async fn report_all_release_survives_modifier_release_order() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106:74;2u\x1b[106;1:3u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106:74;2:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_does_not_forward_release_for_consumed_prefix() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[98;5u\x1b[98;5:3u".to_vec()); - - assert_eq!(app.state.mode, Mode::Prefix); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_preserves_shift_enter_for_modify_other_keys_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - runtime.test_process_pty_bytes(b"\x1b[>4;1m"); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[13;2u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[27;2;13~") - ); - } - - #[tokio::test] - async fn route_client_input_splits_multi_event_payloads_before_forwarding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"ab".to_vec()); - - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"a")); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"b")); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_forwards_multilingual_ime_text_to_focused_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let text = "中日한🙂"; - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_capacity(80, 24, text.chars().count()); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(text.as_bytes().to_vec()); - - let mut forwarded = Vec::new(); - for _ in text.chars() { - let chunk = rx.recv().await.unwrap(); - forwarded.extend_from_slice(&chunk); - } - assert_eq!(forwarded, text.as_bytes()); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_forwards_long_voice_like_cjk_text_without_truncation() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(64); - let char_count = text.chars().count(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, char_count); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(text.as_bytes().to_vec()); - - let mut forwarded = Vec::new(); - for _ in 0..char_count { - let chunk = rx.recv().await.unwrap(); - forwarded.extend_from_slice(&chunk); - } - assert_eq!(forwarded, text.as_bytes()); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn route_client_input_handles_mouse_events() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - - // Send a mouse scroll-up event via SGR encoding. - let mouse_bytes = b"\x1b[<64;10;5M".to_vec(); - // This should not panic even though mouse handling is simplified - // in headless mode. - app.route_client_input(mouse_bytes); - // No assertions on specific behavior — just no panic. - } - - #[test] - fn route_client_input_advances_onboarding_modal() { - let mut app = test_app(); - app.state.mode = Mode::Onboarding; - - app.route_client_input(b"\r".to_vec()); - - assert_eq!(app.state.mode, Mode::Settings); - assert_eq!( - app.state.settings.section, - state::SettingsSection::Integrations - ); - } - - #[test] - fn route_client_input_pastes_bracketed_text_into_rename_modal() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameTab; - app.state.name_input = "2".into(); - app.state.name_input_replace_on_type = true; - - app.route_client_input(b"\x1b[200~feature/logs\x1b[201~".to_vec()); - - assert_eq!(app.state.name_input, "feature/logs"); - assert!(!app.state.name_input_replace_on_type); - } - - #[test] - fn route_client_input_rename_enter_submits_through_api_path() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("old")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameWorkspace; - app.state.name_input = "new".into(); - - app.route_client_input(b"\r".to_vec()); - - assert_eq!(app.state.workspaces[0].custom_name.as_deref(), Some("new")); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::WorkspaceRenamed) - })); - } - - #[test] - fn route_client_input_context_menu_enter_submits_through_api_path() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.confirm_close = false; - app.state.context_menu = Some(state::ContextMenuState { - kind: state::ContextMenuKind::Workspace { ws_idx: 1 }, - x: 2, - y: 2, - list: state::MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - - app.route_client_input(b"\r".to_vec()); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "a"); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::WorkspaceClosed) - })); - } - - #[test] - fn raw_ctrl_v_decodes_as_modal_paste_shortcut() { - let events = crate::raw_input::parse_raw_input_bytes_sync(&[0x16]); - let Some(crate::raw_input::RawInputEvent::Key(key)) = events.first() else { - panic!("expected ctrl-v key event"); - }; - - assert!(input::is_modal_paste_shortcut(&key.as_key_event())); - } - - #[test] - fn route_client_events_pastes_text_into_new_linked_worktree_modal() { - let mut app = test_app(); - app.state.mode = Mode::NewLinkedWorktree; - app.state.name_input = "generated-branch".into(); - app.state.name_input_replace_on_type = true; - app.state.worktree_create = Some(state::WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "generated-branch".into(), - checkout_path: "/repo/herdr-generated-branch".into(), - error: None, - creating: false, - }); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste( - "feature/linear-302".into(), - )], - true, - ); - - assert_eq!(app.state.name_input, "feature/linear-302"); - assert_eq!( - app.state - .worktree_create - .as_ref() - .map(|create| create.branch.as_str()), - Some("feature/linear-302") - ); - } - - #[tokio::test] - async fn route_client_events_pastes_only_into_popup() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("tiled"); - let focused = workspace.focused_pane_id().unwrap(); - let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, tiled_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12); - app.install_test_popup_runtime(popup_runtime); - assert!(app - .state - .should_capture_host_mouse_from(&app.terminal_runtimes)); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste("popup-only".into())], - true, - ); - - assert_eq!( - popup_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"popup-only") - ); - assert!(tiled_rx.try_recv().is_err()); - - app.route_client_events( - vec![raw_key( - KeyCode::Char('x'), - KeyModifiers::NONE, - KeyEventKind::Press, - )], - true, - ); - assert_eq!( - popup_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"x") - ); - assert!(tiled_rx.try_recv().is_err()); - - app.state.mode = Mode::Settings; - app.route_client_events( - vec![raw_key( - KeyCode::Char('y'), - KeyModifiers::NONE, - KeyEventKind::Repeat, - )], - true, - ); - assert_eq!( - popup_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"y") - ); - assert!(tiled_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_events_discards_paste_when_popup_runtime_is_missing() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("tiled"); - let focused = workspace.focused_pane_id().unwrap(); - let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, tiled_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let install_missing_popup = |app: &mut App| { - let popup_terminal_id = crate::terminal::TerminalId::alloc(); - app.state.terminals.insert( - popup_terminal_id.clone(), - crate::terminal::TerminalState::new( - popup_terminal_id.clone(), - std::path::PathBuf::from("/popup"), - ), - ); - app.state.popup_pane = Some(state::PopupPaneState { - pane_id: crate::layout::PaneId::alloc(), - terminal_id: popup_terminal_id, - width: None, - height: None, - }); - }; - install_missing_popup(&mut app); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste("discard-me".into())], - true, - ); - - assert!(tiled_rx.try_recv().is_err()); - assert!(app.state.popup_pane.is_none()); - } - - #[tokio::test] - async fn popup_mouse_motion_preserves_scrollback() { - let mut app = test_app(); - app.state.mode = Mode::Terminal; - app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24); - let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes( - 40, - 2, - 1024, - b"one\r\ntwo\r\nthree\r\n\x1b[?1003h\x1b[?1006h", - 4, - ); - popup_runtime.scroll_up(1); - assert!(popup_runtime - .scroll_metrics() - .is_some_and(|metrics| metrics.offset_from_bottom > 0)); - app.install_test_popup_runtime(popup_runtime); - let (_, inner) = - crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap(); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Moved, - column: inner.x + 1, - row: inner.y, - modifiers: crossterm::event::KeyModifiers::NONE, - }, - )], - true, - ); - - assert!(popup_rx.try_recv().is_ok()); - assert!(app - .popup_runtime() - .and_then(TerminalRuntime::scroll_metrics) - .is_some_and(|metrics| metrics.offset_from_bottom > 0)); - } - - #[tokio::test] - async fn route_client_events_routes_popup_mouse_when_global_capture_is_disabled() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("tiled"); - let focused = workspace.focused_pane_id().unwrap(); - let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); - tiled_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h"); - workspace.tabs[0].runtimes.insert(focused, tiled_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.mouse_capture = false; - app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24); - - let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12); - popup_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h"); - app.install_test_popup_runtime(popup_runtime); - let (_, inner) = - crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap(); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Down( - crossterm::event::MouseButton::Left, - ), - column: inner.x, - row: inner.y, - modifiers: crossterm::event::KeyModifiers::NONE, - }, - )], - true, - ); - - assert!(popup_rx.try_recv().is_ok()); - assert!(tiled_rx.try_recv().is_err()); - } - - #[test] - fn route_client_input_closes_release_notes_modal() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::ReleaseNotes; - app.state.release_notes = Some(release_notes_state()); - - app.route_client_input(b"\x1b".to_vec()); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.release_notes.is_none()); - } - - #[test] - fn route_client_input_closes_settings_modal() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Settings; - app.state.settings.original_theme = Some(app.state.theme_name.clone()); - app.state.settings.original_palette = Some(app.state.palette.clone()); - - app.route_client_input(b"\x1b".to_vec()); - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn route_client_input_updates_host_terminal_theme_from_osc_response() { - let mut app = test_app(); - - app.route_client_input(b"\x1b]11;#123456\x07\x1b]4;7;rgb:aaaa/bbbb/cccc\x1b\\".to_vec()); - - assert_eq!( - app.state.host_terminal_theme.background, - Some(crate::terminal_theme::RgbColor { - r: 0x12, - g: 0x34, - b: 0x56, - }) - ); - assert_eq!( - app.state.host_terminal_theme.palette[7], - Some(crate::terminal_theme::RgbColor { - r: 0xaa, - g: 0xbb, - b: 0xcc, - }) - ); - - app.route_client_input(crate::raw_input::GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec()); - assert_eq!( - app.state.host_terminal_theme.palette[7], - Some(crate::terminal_theme::RgbColor { - r: 0xaa, - g: 0xbb, - b: 0xcc, - }) - ); - - app.route_client_input(b"\x1b]4;7;rgb:dddd/eeee/ffff\x1b\\".to_vec()); - assert_eq!( - app.state.host_terminal_theme.palette[7], - Some(crate::terminal_theme::RgbColor { - r: 0xdd, - g: 0xee, - b: 0xff, - }) - ); - } - - #[tokio::test] - async fn route_client_input_does_not_forward_incomplete_osc_introducer_to_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 1); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b]".to_vec()); - - assert!(rx.try_recv().is_err()); - } } diff --git a/src/app/popup.rs b/src/app/popup.rs index c5877987..8bcd8ae3 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -37,18 +37,6 @@ impl App { true } - pub(crate) fn try_route_paste_to_popup(&mut self, text: &str) -> bool { - if self.state.popup_pane.is_none() { - return false; - } - let Some(runtime) = self.popup_runtime() else { - self.close_popup_pane(); - return true; - }; - let _ = runtime.try_send_paste(text.to_owned()); - true - } - pub(crate) fn spawn_popup_shell_command( &mut self, command: &str, @@ -218,7 +206,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/runtime.rs b/src/app/runtime.rs index bbe5038d..99769e7b 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -5,7 +5,6 @@ use std::time::Duration; use super::{ background_update_check_enabled, App, AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL, - SELECTION_AUTOSCROLL_INTERVAL, }; fn retain_detached_process_after_wait( pid: u32, @@ -29,10 +28,6 @@ impl App { } pub(crate) fn shutdown_terminal_runtime(&mut self, terminal_id: crate::terminal::TerminalId) { - let target = super::TerminalInputTarget { - terminal_id: terminal_id.clone(), - }; - self.release_input_target_headless(&target); if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { runtime.shutdown(); } @@ -45,28 +40,6 @@ impl App { } } - /// Clears temporary copied-token highlights, such as after double-click copy. - pub(crate) fn clear_due_selection_highlight(&mut self, now: Instant) -> bool { - if self - .selection_highlight_clear_deadline - .is_none_or(|deadline| now < deadline) - { - return false; - } - - self.selection_highlight_clear_deadline = None; - if self - .state - .selection - .as_ref() - .is_some_and(|selection| !selection.is_in_progress()) - { - self.state.clear_selection(); - return true; - } - false - } - pub(crate) fn sync_agent_metadata_deadline(&mut self) { self.agent_metadata_deadline = self.state.next_agent_metadata_expiry(); } @@ -98,84 +71,6 @@ impl App { self.sync_agent_metadata_deadline(); } - pub(crate) fn tick_selection_autoscroll(&mut self, now: Instant) { - let Some(autoscroll) = self.state.selection_autoscroll.clone() else { - // Self-heal: state cleared but deadline leaked - self.selection_autoscroll_deadline = None; - return; - }; - - // Selection must still be in progress for autoscroll to continue - let Some(pane_id) = self.state.selection.as_ref().map(|s| s.pane_id) else { - self.stop_selection_autoscroll(); - return; - }; - if !self - .state - .selection - .as_ref() - .is_some_and(|s| s.is_dragging()) - { - self.stop_selection_autoscroll(); - return; - } - - // Rect-change detection: if inner_rect changed since drag, stop - let current_rect = self - .state - .pane_info_by_id(pane_id) - .map(|info| info.inner_rect); - if current_rect != Some(autoscroll.inner_rect) { - self.stop_selection_autoscroll(); - return; - } - - // Scrollback boundary detection via ScrollMetrics — fail-closed if unavailable - let Some(metrics) = self - .state - .pane_scroll_metrics(&self.terminal_runtimes, pane_id) - else { - self.stop_selection_autoscroll(); - return; - }; - match autoscroll.direction { - crate::app::state::SelectionAutoscrollDirection::Up => { - let at_top = metrics.offset_from_bottom >= metrics.max_offset_from_bottom; - if at_top { - self.stop_selection_autoscroll(); - return; - } - self.state - .scroll_pane_up(&self.terminal_runtimes, pane_id, 1); - } - crate::app::state::SelectionAutoscrollDirection::Down => { - let at_bottom = metrics.offset_from_bottom == 0; - if at_bottom { - self.stop_selection_autoscroll(); - return; - } - self.state - .scroll_pane_down(&self.terminal_runtimes, pane_id, 1); - } - } - - // Extend selection cursor to last known mouse position - self.state.update_selection_cursor( - &self.terminal_runtimes, - pane_id, - autoscroll.last_mouse_screen_col, - autoscroll.last_mouse_screen_row, - ); - - // Reschedule - self.selection_autoscroll_deadline = Some(now + SELECTION_AUTOSCROLL_INTERVAL); - } - - pub(crate) fn stop_selection_autoscroll(&mut self) { - self.state.stop_selection_autoscroll_state(); - self.selection_autoscroll_deadline = None; - } - pub(crate) fn can_render_now(&self, now: Instant) -> bool { match self.last_render_at { Some(last_render_at) => now.duration_since(last_render_at) >= MIN_RENDER_INTERVAL, @@ -200,7 +95,10 @@ impl App { } pub(crate) fn run_auto_update_check(&mut self) { - if !background_update_check_enabled(self.no_session, self.update_version_check_enabled) { + if !background_update_check_enabled( + self.policy.background_updates, + self.update_version_check_enabled, + ) { self.next_auto_update_check = None; return; } @@ -220,7 +118,10 @@ impl App { } pub(crate) fn run_agent_manifest_update_check(&mut self) { - if !background_update_check_enabled(self.no_session, self.update_manifest_check_enabled) { + if !background_update_check_enabled( + self.policy.background_updates, + self.update_manifest_check_enabled, + ) { self.next_agent_manifest_update_check = None; return; } @@ -250,7 +151,6 @@ impl App { self.toast_deadline, self.state.next_pending_agent_notification_deadline(), self.state.next_managed_agent_deadline(), - self.copy_feedback_deadline, include_git_refresh .then(|| self.git_refresh_deadline()) .flatten(), @@ -259,8 +159,6 @@ impl App { self.agent_metadata_deadline, self.pending_agent_resume_deadline, self.session_save_deadline, - self.selection_autoscroll_deadline, - self.selection_highlight_clear_deadline, self.next_tab_bar_status_deadline(), render_deadline, ] @@ -275,6 +173,7 @@ impl App { .1 } + #[cfg(test)] pub(crate) fn drain_all_internal_events(&mut self) -> bool { let mut changed = false; loop { @@ -288,6 +187,7 @@ impl App { changed } + #[cfg(test)] fn drain_internal_events_up_to(&mut self, limit: usize) -> (bool, bool) { let mut had_event = false; let mut changed = false; @@ -296,7 +196,7 @@ impl App { break; }; had_event = true; - changed |= self.handle_internal_event_with_prefix_sync(ev); + changed |= self.handle_internal_event_with_render_impact(ev); } (had_event, changed) } @@ -305,7 +205,6 @@ impl App { #[cfg(test)] mod tests { use super::*; - use crate::app::state; use crate::workspace::Workspace; #[test] @@ -332,7 +231,7 @@ mod tests { fn test_app_with_pane() -> (super::super::App, crate::layout::PaneId) { let mut app = super::super::App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, tokio::sync::mpsc::unbounded_channel().1, crate::api::EventHub::default(), @@ -351,160 +250,4 @@ mod tests { }); (app, pane_id) } - - #[test] - fn tick_selection_autoscroll_stops_when_metrics_unavailable() { - // Without a runtime, pane_scroll_metrics returns None. - // Fail-closed: stop autoscroll instead of rescheduling forever. - let (mut app, pane_id) = test_app_with_pane(); - let now = Instant::now(); - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - // Drag to a different cell so it becomes Dragging - sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None); - app.state.selection = Some(sel); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 5, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - // Should stop because no runtime metrics available - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_when_selection_done() { - let (mut app, pane_id) = test_app_with_pane(); - let now = Instant::now(); - // Create a selection that is already finished (not in progress) - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - // Drag to a different cell so it becomes visible, then finish - sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None); - sel.finish(); // now it's Done, not in progress - app.state.selection = Some(sel); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_when_selection_cleared() { - let (mut app, _pane_id) = test_app_with_pane(); - let now = Instant::now(); - app.state.selection = None; - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_when_selection_anchored() { - // Anchored (click, no drag) should not keep the timer running. - let (mut app, pane_id) = test_app_with_pane(); - let now = Instant::now(); - app.state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - /// Creates an app with a real TerminalRuntime (no PTY) so scroll_metrics - /// returns meaningful data. Uses test_with_scrollback_bytes. - fn test_app_with_runtime( - cols: u16, - rows: u16, - bytes: &[u8], - ) -> (super::super::App, crate::layout::PaneId) { - let mut app = super::super::App::new( - &crate::config::Config::default(), - true, - None, - tokio::sync::mpsc::unbounded_channel().1, - crate::api::EventHub::default(), - ); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let runtime = - crate::terminal::TerminalRuntime::test_with_scrollback_bytes(cols, rows, 0, bytes); - ws.tabs[0].runtimes.insert(pane_id, runtime); - app.state.workspaces.push(ws); - app.state.active = Some(0); - app.state.view.pane_infos.push(crate::layout::PaneInfo { - id: pane_id, - rect: ratatui::layout::Rect::new(0, 0, cols, rows), - inner_rect: ratatui::layout::Rect::new(0, 0, cols, rows), - scrollbar_rect: None, - borders: ratatui::widgets::Borders::NONE, - is_focused: true, - }); - (app, pane_id) - } - - #[tokio::test] - async fn tick_selection_autoscroll_stops_at_scrollback_top() { - // Create a runtime with no scrollback content — we're already at - // the top (offset_from_bottom == max_offset_from_bottom). - let (mut app, pane_id) = test_app_with_runtime(80, 24, &[]); - let now = Instant::now(); - let mut sel = crate::selection::Selection::anchor(pane_id, 5, 5, None); - sel.drag(0, 0, ratatui::layout::Rect::new(0, 0, 80, 24), None); - app.state.selection = Some(sel); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Up, - last_mouse_screen_col: 0, - last_mouse_screen_row: 0, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - // At scrollback top, can't scroll further up — should stop - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[tokio::test] - async fn tick_selection_autoscroll_stops_at_scrollback_bottom() { - // Create a runtime with no scrollback content — we're already at - // the bottom (offset_from_bottom == 0). - let (mut app, pane_id) = test_app_with_runtime(80, 24, &[]); - let now = Instant::now(); - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None); - app.state.selection = Some(sel); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 5, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - // At scrollback bottom, can't scroll further down — should stop - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } } diff --git a/src/app/runtime_mutations.rs b/src/app/runtime_mutations.rs deleted file mode 100644 index d3d767e7..00000000 --- a/src/app/runtime_mutations.rs +++ /dev/null @@ -1,198 +0,0 @@ -use crate::api::schema::{ - EmptyParams, LayoutSetSplitRatioParams, Method, PaneFocusDirectionParams, PaneInputSetParams, - PaneRenameParams, PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget, - PaneZoomParams, TabCreateParams, TabMoveParams, TabRenameParams, TabTarget, - WorkspaceCloseParams, WorkspaceCreateParams, WorkspaceMoveBlockParams, WorkspaceMoveParams, - WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams, WorktreeOpenParams, - WorktreeRemoveParams, -}; - -use super::App; - -impl App { - pub(crate) fn dispatch_runtime_mutation(&mut self, id: &'static str, method: Method) -> String { - self.dispatch_api_request(id, method) - } - - pub(crate) fn dispatch_deferred_runtime_mutation( - &mut self, - id: &'static str, - method: Method, - ) -> Option { - self.dispatch_deferred_api_request(id, method) - } - - pub(crate) fn runtime_workspace_focus( - &mut self, - id: &'static str, - workspace_id: String, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceFocus(WorkspaceTarget { workspace_id })) - } - - pub(crate) fn runtime_workspace_create( - &mut self, - id: &'static str, - params: WorkspaceCreateParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceCreate(params)) - } - - pub(crate) fn runtime_workspace_rename( - &mut self, - id: &'static str, - params: WorkspaceRenameParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceRename(params)) - } - - pub(crate) fn runtime_workspace_move( - &mut self, - id: &'static str, - params: WorkspaceMoveParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceMove(params)) - } - - pub(crate) fn runtime_workspace_move_block( - &mut self, - id: &'static str, - params: WorkspaceMoveBlockParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceMoveBlock(params)) - } - - pub(crate) fn runtime_workspace_close_group( - &mut self, - id: &'static str, - workspace_id: String, - ) -> String { - self.dispatch_runtime_mutation( - id, - Method::WorkspaceClose(WorkspaceCloseParams { - workspace_id, - close_group: true, - }), - ) - } - - pub(crate) fn runtime_tab_create( - &mut self, - id: &'static str, - params: TabCreateParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::TabCreate(params)) - } - - pub(crate) fn runtime_tab_focus(&mut self, id: &'static str, tab_id: String) -> String { - self.dispatch_runtime_mutation(id, Method::TabFocus(TabTarget { tab_id })) - } - - pub(crate) fn runtime_tab_rename( - &mut self, - id: &'static str, - params: TabRenameParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::TabRename(params)) - } - - pub(crate) fn runtime_tab_move(&mut self, id: &'static str, params: TabMoveParams) -> String { - self.dispatch_runtime_mutation(id, Method::TabMove(params)) - } - - pub(crate) fn runtime_tab_close(&mut self, id: &'static str, tab_id: String) -> String { - self.dispatch_runtime_mutation(id, Method::TabClose(TabTarget { tab_id })) - } - - pub(crate) fn runtime_server_reload_config(&mut self, id: &'static str) -> String { - self.dispatch_runtime_mutation(id, Method::ServerReloadConfig(EmptyParams::default())) - } - - pub(crate) fn runtime_pane_focus(&mut self, id: &'static str, pane_id: String) -> String { - self.dispatch_runtime_mutation(id, Method::PaneFocus(PaneTarget { pane_id })) - } - - pub(crate) fn runtime_pane_close(&mut self, id: &'static str, pane_id: String) -> String { - self.dispatch_runtime_mutation(id, Method::PaneClose(PaneTarget { pane_id })) - } - - pub(crate) fn runtime_pane_rename( - &mut self, - id: &'static str, - params: PaneRenameParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneRename(params)) - } - - pub(crate) fn runtime_pane_input_set( - &mut self, - id: &'static str, - params: PaneInputSetParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneInputSet(params)) - } - - pub(crate) fn runtime_pane_focus_direction( - &mut self, - id: &'static str, - params: PaneFocusDirectionParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneFocusDirection(params)) - } - - pub(crate) fn runtime_pane_resize( - &mut self, - id: &'static str, - params: PaneResizeParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneResize(params)) - } - - pub(crate) fn runtime_pane_swap(&mut self, id: &'static str, params: PaneSwapParams) -> String { - self.dispatch_runtime_mutation(id, Method::PaneSwap(params)) - } - - pub(crate) fn runtime_pane_split( - &mut self, - id: &'static str, - params: PaneSplitParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneSplit(params)) - } - - pub(crate) fn runtime_pane_zoom(&mut self, id: &'static str, params: PaneZoomParams) -> String { - self.dispatch_runtime_mutation(id, Method::PaneZoom(params)) - } - - pub(crate) fn runtime_layout_set_split_ratio( - &mut self, - id: &'static str, - params: LayoutSetSplitRatioParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::LayoutSetSplitRatio(params)) - } - - pub(crate) fn runtime_worktree_create_deferred( - &mut self, - id: &'static str, - params: WorktreeCreateParams, - ) -> Option { - self.dispatch_deferred_runtime_mutation(id, Method::WorktreeCreate(params)) - } - - pub(crate) fn runtime_worktree_open( - &mut self, - id: &'static str, - params: WorktreeOpenParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorktreeOpen(params)) - } - - pub(crate) fn runtime_worktree_remove_deferred( - &mut self, - id: &'static str, - params: WorktreeRemoveParams, - ) -> Option { - self.dispatch_deferred_runtime_mutation(id, Method::WorktreeRemove(params)) - } -} diff --git a/src/app/session.rs b/src/app/session.rs index 60d49d52..07bc80a9 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -12,7 +12,7 @@ enum SessionSaveJob { impl App { pub(super) fn schedule_session_save(&mut self) { - if !self.no_session { + if self.policy.persist_session { self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE); } } @@ -46,9 +46,6 @@ impl App { &self.terminal_runtimes, self.state.active, self.state.selected, - self.state.sidebar_width, - self.state.sidebar_section_split, - self.state.collapsed_space_keys.clone(), ); let history = self.persist_pane_history.then(|| { crate::persist::capture_history(&self.state.workspaces, &self.terminal_runtimes) @@ -58,7 +55,7 @@ impl App { } pub(crate) fn start_background_session_save(&mut self) { - if self.no_session { + if !self.policy.persist_session { self.session_save_deadline = None; return; } @@ -88,7 +85,7 @@ impl App { let _ = thread.join(); } - if self.no_session { + if !self.policy.persist_session { self.session_save_deadline = None; return; } diff --git a/src/app/state.rs b/src/app/state.rs index 3b340c7b..0f367093 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,13 +1,10 @@ -use crate::config::{ - Keybinds, NewTerminalCwdConfig, SoundConfig, TabBarPositionConfig, ToastConfig, ToastDelivery, -}; +use crate::config::{Keybinds, NewTerminalCwdConfig, SoundConfig, ToastConfig}; use crossterm::event::{KeyCode, KeyModifiers}; -use ratatui::layout::{Direction, Rect}; +use ratatui::layout::Rect; use ratatui::style::Color; use crate::detect::AgentState; -use crate::layout::{PaneId, PaneInfo, SplitBorder}; -use crate::selection::Selection; +use crate::layout::{PaneId, PaneInfo}; pub(crate) type InstalledPluginRegistry = std::collections::HashMap; @@ -25,36 +22,6 @@ pub(crate) struct PopupPaneState { pub height: Option, } -// --------------------------------------------------------------------------- -// Selection autoscroll types -// --------------------------------------------------------------------------- - -/// Direction of automatic scrolling during text selection drag. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum SelectionAutoscrollDirection { - Up, - Down, -} - -/// State for automatic scrolling during text selection drag. -/// -/// When the cursor hovers in the 1-row hot zone at the top or bottom edge -/// of a pane (or outside the pane), this struct captures the direction and -/// last known mouse position so a recurring 30ms tick can continue scrolling -/// and extending the selection even when the mouse is not moving. -#[derive(Clone, Debug)] -pub(crate) struct SelectionAutoscroll { - pub direction: SelectionAutoscrollDirection, - pub last_mouse_screen_col: u16, - pub last_mouse_screen_row: u16, - pub inner_rect: Rect, -} - -#[derive(Clone)] -pub(crate) struct RightClickPassthroughGesture { - pub pane_info: PaneInfo, - pub modifiers: KeyModifiers, -} use crate::terminal_theme::{HostAppearance, TerminalTheme}; use crate::workspace::Workspace; @@ -65,7 +32,6 @@ use crate::workspace::Workspace; /// All colors used by the UI. Derived from a base accent color for now, /// but structured so a full theme system can replace it later. #[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] // all fields defined for theming — some used later pub struct Palette { /// Primary accent (highlight, active borders). pub accent: Color, @@ -709,364 +675,16 @@ impl Palette { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct WorkspaceCardArea { - pub ws_idx: usize, - pub rect: Rect, - pub indented: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeCreateState { - pub source_workspace_id: String, - pub source_checkout_path: std::path::PathBuf, - pub source_existing_membership: Option, - pub source_repo_root: std::path::PathBuf, - pub repo_key: String, - pub repo_name: String, - pub branch: String, - pub checkout_path: std::path::PathBuf, - pub error: Option, - pub creating: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeRemoveState { - pub workspace_id: String, - pub repo_root: std::path::PathBuf, - pub path: std::path::PathBuf, - pub error: Option, - pub removing: bool, - pub force_confirmation: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeOpenEntry { - pub path: std::path::PathBuf, - pub branch: Option, - pub is_linked_worktree: bool, - pub already_open_ws_idx: Option, -} - -impl WorktreeOpenEntry { - pub(crate) fn display_name(&self) -> String { - self.branch.clone().unwrap_or_else(|| { - self.path - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_owned) - .unwrap_or_else(|| self.path.display().to_string()) - }) - } - - pub(crate) fn status_label(&self) -> &'static str { - if self.already_open_ws_idx.is_some() { - "open" - } else if self.branch.is_some() { - "" - } else if self.is_linked_worktree { - "detached" - } else { - "root" - } - } - - fn search_text(&self) -> String { - format!( - "{} {} {} {}", - self.display_name(), - self.path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default(), - self.path.display(), - self.status_label() - ) - .to_lowercase() - } - - fn matches_query(&self, query: &str) -> bool { - text_matches_query(query, &self.search_text()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeOpenState { - pub source_workspace_id: String, - pub source_existing_membership: Option, - pub source_checkout_path: std::path::PathBuf, - pub source_repo_root: std::path::PathBuf, - pub repo_key: String, - pub repo_name: String, - pub entries: Vec, - pub selected: usize, - pub query: String, - pub search_focused: bool, - pub error: Option, -} - -impl WorktreeOpenState { - pub(crate) fn filtered_indices(&self) -> Vec { - let query = self.query.trim(); - self.entries - .iter() - .enumerate() - .filter_map(|(idx, entry)| { - (query.is_empty() || entry.matches_query(query)).then_some(idx) - }) - .collect() - } - - pub(crate) fn selected_entry_index(&self) -> Option { - let indices = self.filtered_indices(); - if indices.contains(&self.selected) { - Some(self.selected) - } else { - indices.first().copied() - } - } - - pub(crate) fn normalize_selection(&mut self) { - if let Some(selected) = self.selected_entry_index() { - self.selected = selected; - } - } - - pub(crate) fn select_previous_filtered(&mut self) { - let indices = self.filtered_indices(); - let Some(current) = self.selected_entry_index() else { - return; - }; - let pos = indices.iter().position(|idx| *idx == current).unwrap_or(0); - self.selected = indices[pos.saturating_sub(1)]; - } - - pub(crate) fn select_next_filtered(&mut self) { - let indices = self.filtered_indices(); - let Some(current) = self.selected_entry_index() else { - return; - }; - let pos = indices.iter().position(|idx| *idx == current).unwrap_or(0); - self.selected = indices[(pos + 1).min(indices.len().saturating_sub(1))]; - } -} - -pub(crate) fn text_matches_query(query: &str, text: &str) -> bool { - let haystack = text.to_lowercase(); - query - .to_lowercase() - .split_whitespace() - .all(|needle| haystack.contains(needle)) -} - -/// Computed view geometry — derived from AppState + terminal size. -/// Updated before each render, consumed by render and mouse handling. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ViewLayout { - Desktop, - Mobile, -} - +/// Geometry for the server-rendered active-tab pane surface. pub struct ViewState { - pub layout: ViewLayout, - pub sidebar_rect: Rect, - pub workspace_card_areas: Vec, - pub tab_bar_rect: Rect, - pub tab_hit_areas: Vec, - pub tab_scroll_left_hit_area: Rect, - pub tab_scroll_right_hit_area: Rect, - pub new_tab_hit_area: Rect, pub terminal_area: Rect, - pub mobile_header_rect: Rect, - pub mobile_menu_hit_area: Rect, - pub toast_hit_area: Rect, pub pane_infos: Vec, - pub split_borders: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { - Onboarding, - ReleaseNotes, - ProductAnnouncement, Navigate, - Prefix, - Copy, Terminal, - RenameWorkspace, - RenameTab, - RenamePane, - NewLinkedWorktree, - OpenExistingWorktree, - ConfirmRemoveWorktree, - Resize, - ConfirmClose, - ContextMenu, - Settings, - GlobalMenu, - KeybindHelp, - Navigator, -} - -impl Mode { - pub(crate) fn mouse_motion_changes_view(self) -> bool { - matches!(self, Self::GlobalMenu | Self::ContextMenu | Self::Navigator) - } - - /// Whether keys in this mode are commands/navigation (an ASCII input source is wanted) rather - /// than free text. This is an explicit **allowlist** of the prefix command/navigation realm: - /// any mode NOT listed defaults to leaving the user's IME alone (the safe default), so adding a - /// new text-entry or overlay mode can never silently force ASCII. Used by - /// `sync_prefix_input_source` (gated by `switch_ascii_input_source_in_prefix`) so multi-level - /// prefix commands keep ASCII until they return to the terminal. - /// - /// Known limitation: the search boxes in `Navigator` and `KeybindHelp` are also held on ASCII, - /// since this `Mode`-level predicate can't see `search_focused` (non-ASCII filtering there - /// would need a runtime check). - pub(crate) fn wants_ascii_input(self) -> bool { - matches!( - self, - Mode::Prefix - | Mode::Navigate - | Mode::Navigator - | Mode::Copy - | Mode::Resize - | Mode::ConfirmClose - | Mode::ConfirmRemoveWorktree - | Mode::ContextMenu - | Mode::GlobalMenu - | Mode::KeybindHelp - ) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum NavigatorTarget { - Workspace { - ws_idx: usize, - }, - Tab { - ws_idx: usize, - tab_idx: usize, - }, - Pane { - ws_idx: usize, - tab_idx: usize, - pane_id: PaneId, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct NavigatorRow { - pub target: NavigatorTarget, - pub depth: u8, - pub label: String, - pub meta: String, - pub status: AgentState, - pub seen: bool, - pub is_current: bool, - pub is_workspace: bool, - pub is_tab: bool, - pub expanded: bool, - pub search_text: String, - /// Whether this row itself matched the active query/state filter, as - /// opposed to being included as ancestor context or cascaded subtree of a - /// matching workspace or tab. Always true when no filter is active. - pub matched: bool, -} - -/// One rendered line in the navigator body. Spacer lines separate workspace -/// groups visually and are not selectable. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum NavigatorDisplayLine { - Spacer, - Row(usize), -} - -pub(crate) fn navigator_display_lines(rows: &[NavigatorRow]) -> Vec { - let mut lines = Vec::with_capacity(rows.len().saturating_mul(2)); - for (idx, row) in rows.iter().enumerate() { - if row.is_workspace && !lines.is_empty() { - lines.push(NavigatorDisplayLine::Spacer); - } - lines.push(NavigatorDisplayLine::Row(idx)); - } - lines -} - -pub(crate) fn navigator_display_index_of_row( - lines: &[NavigatorDisplayLine], - row_idx: usize, -) -> Option { - lines - .iter() - .position(|line| *line == NavigatorDisplayLine::Row(row_idx)) -} - -pub(crate) fn navigator_first_row_at_or_after( - lines: &[NavigatorDisplayLine], - line_idx: usize, -) -> Option { - lines.get(line_idx..)?.iter().find_map(|line| match line { - NavigatorDisplayLine::Row(idx) => Some(*idx), - NavigatorDisplayLine::Spacer => None, - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum NavigatorStateFilter { - Blocked, - Working, - Idle, - Done, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct NavigatorState { - pub query: String, - pub selected: usize, - pub scroll: usize, - pub search_focused: bool, - pub state_filter: Option, - pub expanded_workspaces: std::collections::HashSet, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct CopyModeState { - pub pane_id: PaneId, - pub cursor_row: u16, - pub cursor_col: u16, - pub entry_offset_from_bottom: usize, - pub selection: Option, - pub search: CopyModeSearchState, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CopyModeSelection { - Character, - Linewise { anchor_row: u32 }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CopyModeSearchDirection { - Forward, - Backward, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct CopyModeSearchPrompt { - pub direction: CopyModeSearchDirection, - pub query: String, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct CopyModeSearchState { - pub prompt: Option, - pub query: String, - pub direction: Option, - pub matches: Vec, - pub current: Option, - pub geometry: Option<(u16, u16)>, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -1076,98 +694,6 @@ pub enum AgentPanelSort { Priority, } -// --------------------------------------------------------------------------- -// Settings UI state -// --------------------------------------------------------------------------- - -/// Which section of the settings panel is focused. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SettingsSection { - Theme, - Indicators, - Sound, - Toast, - PaneLabels, - Integrations, -} - -impl SettingsSection { - pub const ALL: &[Self] = &[ - Self::Theme, - Self::Indicators, - Self::Sound, - Self::Toast, - Self::PaneLabels, - Self::Integrations, - ]; - - pub fn label(self) -> &'static str { - match self { - Self::Theme => "theme", - Self::Indicators => "indicators", - Self::Sound => "sound", - Self::Toast => "toasts", - Self::PaneLabels => "pane labels", - Self::Integrations => "integrations", - } - } -} - -/// All built-in theme names in display order. -pub const THEME_NAMES: &[&str] = crate::config::THEME_NAMES; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MenuListState { - pub highlighted: usize, -} - -impl MenuListState { - pub fn new(highlighted: usize) -> Self { - Self { highlighted } - } - - pub fn move_prev(&mut self) { - self.highlighted = self.highlighted.saturating_sub(1); - } - - pub fn move_next(&mut self, item_count: usize) { - if item_count > 0 { - self.highlighted = (self.highlighted + 1).min(item_count - 1); - } - } - - pub fn hover(&mut self, idx: Option) { - if let Some(idx) = idx { - self.highlighted = idx; - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SelectionListState { - pub selected: usize, -} - -impl SelectionListState { - pub fn new(selected: usize) -> Self { - Self { selected } - } - - pub fn move_prev(&mut self) { - self.selected = self.selected.saturating_sub(1); - } - - pub fn move_next(&mut self, item_count: usize) { - if item_count > 0 { - self.selected = (self.selected + 1).min(item_count - 1); - } - } - - pub fn select(&mut self, idx: usize) { - self.selected = idx; - } -} - #[derive(Debug, Clone)] pub struct ThemeRuntimeConfig { pub manual_name: String, @@ -1178,167 +704,6 @@ pub struct ThemeRuntimeConfig { pub legacy_accent: Option, } -pub struct SettingsState { - /// Which section tab is active. - pub section: SettingsSection, - /// Selected item index within the current section. - pub list: SelectionListState, - /// The palette before opening settings (for cancel/restore). - pub original_palette: Option, - /// The theme name before opening settings. - pub original_theme: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum WorkspaceDropTarget { - Before(usize), - End, -} - -pub(crate) enum DragTarget { - WorkspaceReorder { - source_id: crate::app::InputSourceId, - source_ws_idx: usize, - drop_target: Option, - }, - TabReorder { - source_id: crate::app::InputSourceId, - ws_idx: usize, - source_tab_idx: usize, - insert_idx: Option, - }, - WorkspaceListScrollbar { - grab_row_offset: u16, - }, - AgentPanelScrollbar { - grab_row_offset: u16, - }, - PaneSplit { - path: Vec, - direction: Direction, - area: Rect, - grab_offset: u16, - }, - PaneScrollbar { - pane_id: crate::layout::PaneId, - grab_row_offset: u16, - }, - ReleaseNotesScrollbar { - grab_row_offset: u16, - }, - ProductAnnouncementScrollbar { - grab_row_offset: u16, - }, - KeybindHelpScrollbar { - grab_row_offset: u16, - }, - SidebarDivider, - SidebarSectionDivider, -} - -/// Active mouse drag on a split border or sidebar divider. -pub(crate) struct DragState { - pub target: DragTarget, -} - -pub(crate) struct WorkspacePressState { - pub ws_idx: usize, - pub start_col: u16, - pub start_row: u16, -} - -pub(crate) struct TabPressState { - pub ws_idx: usize, - pub tab_idx: usize, - pub start_col: u16, - pub start_row: u16, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ContextMenuKind { - Workspace { - ws_idx: usize, - }, - GitWorkspace { - ws_idx: usize, - is_linked_worktree: bool, - has_worktree_children: bool, - collapsed: bool, - }, - Tab { - ws_idx: usize, - tab_idx: usize, - }, - Pane { - ws_idx: usize, - tab_idx: usize, - pane_id: PaneId, - source_pane_id: Option, - has_manual_label: bool, - right_click_passthrough: bool, - }, -} - -/// Right-click context menu state. -pub struct ContextMenuState { - pub kind: ContextMenuKind, - pub x: u16, - pub y: u16, - pub list: MenuListState, -} - -impl ContextMenuState { - pub fn items(&self) -> Vec<&'static str> { - match self.kind { - ContextMenuKind::Workspace { .. } => vec!["Rename", "Close"], - ContextMenuKind::GitWorkspace { - is_linked_worktree: false, - has_worktree_children: false, - .. - } => vec!["Rename", "Close", "New worktree", "Open worktree..."], - ContextMenuKind::GitWorkspace { - is_linked_worktree: true, - .. - } => vec!["Rename", "Close", "Delete worktree checkout..."], - ContextMenuKind::GitWorkspace { - is_linked_worktree: false, - has_worktree_children: true, - collapsed, - .. - } => vec![ - "Rename", - "Close group", - "New worktree", - "Open worktree...", - if collapsed { "Expand" } else { "Collapse" }, - ], - ContextMenuKind::Tab { .. } => vec!["New tab", "Rename", "Close"], - ContextMenuKind::Pane { - source_pane_id, - has_manual_label, - right_click_passthrough, - .. - } => { - let mut items = vec!["Rename pane"]; - if has_manual_label { - items.push("Clear pane name"); - } - if source_pane_id.is_some() { - items.push("Swap with focused pane"); - } - items.extend(["Split right", "Split down", "Zoom"]); - items.push(if right_click_passthrough { - "Use Herdr right-click menu" - } else { - "Send right-clicks to pane" - }); - items.push("Close pane"); - items - } - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToastKind { NeedsAttention, @@ -1407,20 +772,6 @@ pub struct ProductAnnouncementState { pub preview: bool, } -#[derive(Default)] -pub struct KeybindHelpState { - pub scroll: u16, - pub query: String, - pub search_focused: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SidebarWidthSource { - ConfigDefault, - Persisted, - Manual, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PaneFocusTarget { pub workspace_id: String, @@ -1447,61 +798,16 @@ pub struct AppState { pub(crate) previous_pane_focus: Option, pub selected: usize, pub mode: Mode, - /// Stable workspace identity captured when the close confirmation opens. - pub(crate) confirm_close_workspace_id: Option, pub should_quit: bool, - /// Set when the current client should detach from the persistent session. - /// The server's event loop checks this and handles client detach. - pub detach_requested: bool, - pub request_new_workspace: bool, - pub request_new_tab: bool, - pub request_new_linked_worktree: Option, - pub request_open_existing_worktree: Option, - pub request_new_workspace_cwd: Option, - pub request_remove_linked_worktree: Option, - pub request_submit_worktree_create: bool, - pub request_submit_worktree_open: bool, - pub request_submit_worktree_remove: bool, - pub request_reload_config: bool, /// Set when the headless server should ask attached clients to reload /// their client-local sound config from disk. pub request_client_config_reload: bool, - /// Set when UI interaction requested a clipboard write that must be - /// handled by the outer App/event loop instead of directly from AppState. - pub request_clipboard_write: Option>, - pub creating_new_tab: bool, - pub requested_new_tab_name: Option, - pub pending_workspace_create_cwd: Option, - pub rename_pane_target: Option, - pub worktree_create: Option, - pub worktree_open: Option, - pub worktree_remove: Option, pub worktree_directory: std::path::PathBuf, - pub collapsed_space_keys: std::collections::HashSet, - pub request_complete_onboarding: bool, - pub name_input: String, - pub name_input_replace_on_type: bool, - pub release_notes: Option, /// Latest endpoint-owned release notes, cached outside render paths. pub latest_release_notes: Option, pub product_announcement: Option, - pub keybind_help: KeybindHelpState, - pub navigator: NavigatorState, - pub copy_mode: Option, - pub workspace_scroll: usize, - pub agent_panel_scroll: usize, - pub tab_scroll: usize, - pub tab_scroll_follow_active: bool, - pub mobile_switcher_scroll: usize, - // View geometry (computed before render, consumed by render + mouse) + // Geometry of the most recently computed server pane surface. pub view: ViewState, - pub(crate) drag: Option, - pub(crate) workspace_presses: - std::collections::HashMap, - pub(crate) tab_presses: std::collections::HashMap, - pub selection: Option, - pub selection_autoscroll: Option, - pub context_menu: Option, // Notifications pub update_available: Option, pub update_install_command: String, @@ -1510,7 +816,6 @@ pub struct AppState { pub config_diagnostic: Option, pub toast: Option, pub pending_agent_notifications: std::collections::HashMap, - pub copy_feedback: Option, /// Last reported focus state for the outer terminal hosting herdr. /// None means unsupported or not yet reported, which preserves active-pane suppression. pub outer_terminal_focus: Option, @@ -1519,45 +824,20 @@ pub struct AppState { pub prefix_mods: KeyModifiers, /// Virtual terminal size (columns, rows) used when no client is attached. pub(crate) headless_size: (u16, u16), - pub default_sidebar_width: u16, - pub sidebar_width: u16, - pub sidebar_min_width: u16, - pub sidebar_max_width: u16, - pub mobile_width_threshold: u16, - pub sidebar_width_source: SidebarWidthSource, - pub sidebar_width_auto: bool, - pub sidebar_collapsed: bool, - pub sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig, - /// Ratio of sidebar height allocated to the workspaces section. - pub sidebar_section_split: f32, pub agent_panel_sort: AgentPanelSort, - pub status_indicators: crate::config::StatusIndicatorStyle, /// Transient session-wide projection override for the built-in Agents view. pub agent_view_override: Option, pub sidebar_agents: crate::config::AgentsSidebarConfig, pub sidebar_spaces: crate::config::SpacesSidebarConfig, pub next_agent_state_change_seq: u64, - /// Capture mouse input for Herdr's own mouse UI. When false, Herdr only - /// captures mouse while the focused pane app requests mouse reporting. - pub mouse_capture: bool, - pub copy_on_select: bool, - pub right_click_passthrough_modifiers: Option, - pub right_click_passthrough: Option, - pub redraw_on_focus_gained: bool, - pub mouse_scroll_lines: usize, pub confirm_close: bool, - pub prompt_new_tab_name: bool, - pub prompt_new_workspace_name: bool, pub pane_borders: bool, pub pane_outer_borders: bool, pub pane_scrollbars: bool, pub pane_gaps: bool, pub show_agent_labels_on_pane_borders: bool, - pub hide_tab_bar_when_single_tab: bool, - pub tab_bar_position: TabBarPositionConfig, pub tab_bar_right: Vec, pub tab_bar_right_separator: String, - pub pane_history_persistence: bool, /// Expose the focused pane's cursor anchor to the outer terminal even when /// the pane requested `?25l`. See `[experimental] reveal_hidden_cursor_for_cjk_ime`. pub reveal_hidden_cursor_for_cjk_ime: bool, @@ -1567,18 +847,11 @@ pub struct AppState { pub cjk_ime_agents: Vec, /// DECSCUSR shape parameter (1–6) for the IME anchor cursor. pub cjk_ime_cursor_shape: u8, - /// While prefix mode is active, switch the macOS host input source to an - /// ASCII-capable layout so prefix commands register as ASCII even when a - /// CJK IME is active. macOS only; a no-op elsewhere. See - /// `[experimental] switch_ascii_input_source_in_prefix`. - pub switch_ascii_input_source_in_prefix: bool, pub kitty_graphics_enabled: bool, pub default_shell: String, pub shell_mode: crate::config::ShellModeConfig, pub new_terminal_cwd: NewTerminalCwdConfig, pub pane_scrollback_limit_bytes: usize, - #[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth - pub accent: Color, pub sound: SoundConfig, pub toast_config: ToastConfig, pub keybinds: Keybinds, @@ -1592,16 +865,11 @@ pub struct AppState { pub host_terminal_appearance: Option, /// True when the foreground host explicitly reported appearance via Mode 2031. pub host_terminal_appearance_explicit: bool, - /// Settings panel state. - pub settings: SettingsState, - /// Cached integration recommendations for onboarding/settings UI. + /// Cached integration recommendations and detection manifest summaries. pub integration_recommendations: Vec, - /// Cached detection manifest source/version summaries for runtime/API status. pub agent_manifest_summaries: Vec, /// Cached remote detection manifest update diagnostics for runtime/API status. pub agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus, - /// Result messages from the latest integration install action. - pub integration_install_messages: Vec, /// Installed or linked plugins known to this running Herdr instance. pub(crate) installed_plugins: InstalledPluginRegistry, /// Pane ids opened through the plugin pane API. @@ -1612,14 +880,10 @@ pub struct AppState { pub(crate) plugin_command_logs: Vec, pub(crate) next_plugin_command_log_id: u64, pub(crate) plugin_commands_in_flight: usize, - /// Highlight state for the bottom-right global launcher menu. - pub global_menu: MenuListState, /// Resolved host terminal default colors for theming embedded panes. pub host_terminal_theme: TerminalTheme, /// Last known foreground host terminal cell size in pixels. pub(crate) host_cell_size: crate::kitty_graphics::HostCellSize, - /// Exact pixel provenance only while one confirmed SGR report is dispatched. - pub(crate) host_mouse_pixels: Option, /// Set when a persisted session snapshot would change. pub session_dirty: bool, /// Terminal runtimes that should be shut down by the app/runtime layer @@ -1636,18 +900,6 @@ impl AppState { self.pane_id_aliases.remove(&pane_id.raw()); } - pub fn sound_enabled(&self) -> bool { - self.sound.enabled - } - - pub fn toast_delivery(&self) -> ToastDelivery { - self.toast_config.delivery - } - - pub fn agent_border_labels_enabled(&self) -> bool { - self.show_agent_labels_on_pane_borders - } - pub(crate) fn pane_exposes_host_cursor( &self, _ws_idx: usize, @@ -1656,27 +908,14 @@ impl AppState { true } - pub(crate) fn integration_updates_available(&self) -> bool { - self.integration_recommendations - .iter() - .any(|item| item.state == crate::integration::IntegrationStatusKind::Outdated) - } - pub(crate) fn refresh_agent_manifest_summaries(&mut self) { self.agent_manifest_summaries = crate::detect::manifest::manifest_summaries(); } - pub(crate) fn global_menu_attention_badge_visible(&self) -> bool { - self.update_available.is_some() || self.integration_updates_available() - } - - pub(crate) fn global_menu_item_has_badge(&self, item: &str) -> bool { - (item == "update ready" && self.update_available.is_some()) - || (item == "settings" && self.integration_updates_available()) - } - - pub(crate) fn settings_section_has_badge(&self, section: SettingsSection) -> bool { - section == SettingsSection::Integrations && self.integration_updates_available() + pub(crate) fn integration_updates_available(&self) -> bool { + self.integration_recommendations + .iter() + .any(crate::integration::IntegrationRecommendation::needs_install) } pub(crate) fn app_surface_pane_ids(&self) -> std::collections::HashSet { @@ -1703,24 +942,9 @@ impl AppState { &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) -> bool { - self.mode == Mode::Terminal - && self - .active - .and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx)) - .is_some_and(crate::terminal::TerminalRuntime::mouse_reporting_enabled) - } - - pub(crate) fn should_capture_host_mouse_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> bool { - self.mouse_capture - || self.popup_pane.is_some() - || self.focused_pane_requests_mouse_capture_from(terminal_runtimes) - } - - pub fn is_prefix_key(&self, key: &crate::input::TerminalKey) -> bool { - crate::config::terminal_key_matches_combo(key, (self.prefix_code, self.prefix_mods)) + self.active + .and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx)) + .is_some_and(crate::terminal::TerminalRuntime::mouse_reporting_enabled) } pub fn estimate_pane_size(&self) -> (u16, u16) { @@ -1757,26 +981,6 @@ impl AppState { terminal_runtimes.get(terminal_id) } - #[cfg(test)] - pub(crate) fn runtime_for_pane<'a>( - &'a self, - terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - ) -> Option<&'a crate::terminal::TerminalRuntime> { - self.workspaces.iter().find_map(|ws| { - #[cfg(test)] - if let Some(runtime) = ws.test_runtimes.get(&pane_id) { - return Some(runtime); - } - #[cfg(test)] - if let Some(runtime) = ws.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id)) { - return Some(runtime); - } - let terminal_id = ws.terminal_id(pane_id)?; - terminal_runtimes.get(terminal_id) - }) - } - pub(crate) fn focused_runtime_in_workspace<'a>( &'a self, terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, @@ -1787,6 +991,28 @@ impl AppState { self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) } + pub(crate) fn pane_visible_on_active_surface( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> bool { + if self.active != Some(ws_idx) { + return false; + } + let Some(tab) = self + .workspaces + .get(ws_idx) + .and_then(|workspace| workspace.active_tab()) + else { + return false; + }; + if tab.zoomed { + tab.layout.focused() == pane_id + } else { + tab.layout.pane_ids().contains(&pane_id) + } + } + pub fn is_active_pane( &self, ws_idx: usize, @@ -1839,66 +1065,15 @@ impl AppState { previous_pane_focus: None, selected: 0, mode: Mode::Navigate, - confirm_close_workspace_id: None, should_quit: false, - detach_requested: false, - request_new_workspace: false, - request_new_tab: false, - request_new_linked_worktree: None, - request_open_existing_worktree: None, - request_new_workspace_cwd: None, - request_remove_linked_worktree: None, - request_submit_worktree_create: false, - request_submit_worktree_open: false, - request_submit_worktree_remove: false, - request_reload_config: false, request_client_config_reload: false, - request_clipboard_write: None, - creating_new_tab: false, - requested_new_tab_name: None, - pending_workspace_create_cwd: None, - rename_pane_target: None, - worktree_create: None, - worktree_open: None, - worktree_remove: None, worktree_directory: std::path::PathBuf::from("/tmp/herdr-worktrees"), - collapsed_space_keys: std::collections::HashSet::new(), - request_complete_onboarding: false, - name_input: String::new(), - name_input_replace_on_type: false, - release_notes: None, latest_release_notes: None, product_announcement: None, - keybind_help: KeybindHelpState::default(), - navigator: NavigatorState::default(), - copy_mode: None, - workspace_scroll: 0, - agent_panel_scroll: 0, - tab_scroll: 0, - tab_scroll_follow_active: true, - mobile_switcher_scroll: 0, view: ViewState { - layout: ViewLayout::Desktop, - sidebar_rect: Rect::default(), - workspace_card_areas: Vec::new(), - tab_bar_rect: Rect::default(), - tab_hit_areas: Vec::new(), - tab_scroll_left_hit_area: Rect::default(), - tab_scroll_right_hit_area: Rect::default(), - new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), - mobile_header_rect: Rect::default(), - mobile_menu_hit_area: Rect::default(), - toast_hit_area: Rect::default(), pane_infos: Vec::new(), - split_borders: Vec::new(), }, - drag: None, - workspace_presses: std::collections::HashMap::new(), - tab_presses: std::collections::HashMap::new(), - selection: None, - selection_autoscroll: None, - context_menu: None, update_available: None, update_install_command: "herdr update".into(), latest_release_notes_available: false, @@ -1906,7 +1081,6 @@ impl AppState { config_diagnostic: None, toast: None, pending_agent_notifications: std::collections::HashMap::new(), - copy_feedback: None, outer_terminal_focus: None, prefix_code: KeyCode::Char('b'), prefix_mods: KeyModifiers::CONTROL, @@ -1914,52 +1088,28 @@ impl AppState { crate::config::DEFAULT_HEADLESS_COLS, crate::config::DEFAULT_HEADLESS_ROWS, ), - default_sidebar_width: 26, - sidebar_width: 26, - sidebar_min_width: 18, - sidebar_max_width: 36, - mobile_width_threshold: crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD, - sidebar_width_source: SidebarWidthSource::ConfigDefault, - sidebar_width_auto: false, - sidebar_collapsed: false, - sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact, - sidebar_section_split: 0.5, agent_panel_sort: AgentPanelSort::Spaces, - status_indicators: crate::config::StatusIndicatorStyle::Dots, agent_view_override: None, sidebar_agents: crate::config::AgentsSidebarConfig::default(), sidebar_spaces: crate::config::SpacesSidebarConfig::default(), next_agent_state_change_seq: 0, - mouse_capture: true, - copy_on_select: true, - right_click_passthrough_modifiers: None, - right_click_passthrough: None, - redraw_on_focus_gained: true, - mouse_scroll_lines: crate::config::DEFAULT_MOUSE_SCROLL_LINES, confirm_close: true, - prompt_new_tab_name: true, - prompt_new_workspace_name: false, pane_borders: true, pane_outer_borders: true, pane_scrollbars: true, pane_gaps: false, show_agent_labels_on_pane_borders: false, - hide_tab_bar_when_single_tab: false, - tab_bar_position: TabBarPositionConfig::Top, tab_bar_right: Vec::new(), tab_bar_right_separator: " ".into(), - pane_history_persistence: false, reveal_hidden_cursor_for_cjk_ime: false, cjk_ime_agent_filter_configured: false, cjk_ime_agents: Vec::new(), cjk_ime_cursor_shape: 2, // steady_block - switch_ascii_input_source_in_prefix: false, kitty_graphics_enabled: false, default_shell: String::new(), shell_mode: crate::config::ShellModeConfig::Auto, new_terminal_cwd: NewTerminalCwdConfig::Follow, pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES, - accent: Color::Cyan, sound: SoundConfig { enabled: false, ..SoundConfig::default() @@ -1978,27 +1128,18 @@ impl AppState { }, host_terminal_appearance: None, host_terminal_appearance_explicit: false, - settings: SettingsState { - section: SettingsSection::Theme, - list: SelectionListState::new(0), - original_palette: None, - original_theme: None, - }, integration_recommendations: Vec::new(), agent_manifest_summaries: Vec::new(), agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus::default(), - integration_install_messages: Vec::new(), installed_plugins: std::collections::HashMap::new(), plugin_panes: std::collections::HashMap::new(), popup_pane: None, plugin_command_logs: Vec::new(), next_plugin_command_log_id: 1, plugin_commands_in_flight: 0, - global_menu: MenuListState::new(0), host_terminal_theme: TerminalTheme::default(), host_cell_size: crate::kitty_graphics::HostCellSize::default(), - host_mouse_pixels: None, session_dirty: false, terminal_runtime_shutdowns: Vec::new(), } @@ -2063,52 +1204,12 @@ impl AppState { self.pending_agent_notifications.is_empty(), "empty app state must not keep pending agent notifications" ); - assert!( - self.copy_mode.is_none(), - "empty app state must not keep copy mode" - ); - assert!( - self.rename_pane_target.is_none(), - "empty app state must not keep rename pane target" - ); - assert!( - self.selection.is_none(), - "empty app state must not keep text selection" - ); - assert!( - self.selection_autoscroll.is_none(), - "empty app state must not keep selection autoscroll" - ); if let Some(toast) = &self.toast { assert!( toast.target.is_none(), "empty app state must not keep pane-targeted toast" ); } - assert!( - self.right_click_passthrough.is_none(), - "empty app state must not keep right-click passthrough gesture" - ); - assert!( - self.drag.is_none(), - "empty app state must not keep drag state" - ); - assert!( - self.workspace_presses.is_empty(), - "empty app state must not keep workspace press state" - ); - assert!( - self.tab_presses.is_empty(), - "empty app state must not keep tab press state" - ); - assert!( - self.context_menu.is_none(), - "empty app state must not keep context menu" - ); - assert!( - self.host_mouse_pixels.is_none(), - "empty app state must not keep host mouse pixel provenance" - ); return; } @@ -2183,25 +1284,6 @@ impl AppState { workspace_id ); }; - let assert_workspace_index = |ws_idx: usize, context: &str| { - assert!( - ws_idx < self.workspaces.len(), - "{context} references workspace index {} out of bounds for {} workspaces", - ws_idx, - self.workspaces.len() - ); - }; - let assert_tab_index = |ws_idx: usize, tab_idx: usize, context: &str| { - assert_workspace_index(ws_idx, context); - assert!( - tab_idx < self.workspaces[ws_idx].tabs.len(), - "{context} references tab index {} out of bounds for workspace {} with {} tabs", - tab_idx, - ws_idx, - self.workspaces[ws_idx].tabs.len() - ); - }; - for (&raw, &pane_id) in &self.pane_id_aliases { assert_live_pane(pane_id, &format!("raw pane alias {raw}")); } @@ -2243,96 +1325,6 @@ impl AppState { for &pane_id in self.plugin_panes.keys() { assert_live_pane(pane_id, "plugin pane record"); } - if let Some(copy_mode) = &self.copy_mode { - assert_live_pane(copy_mode.pane_id, "copy mode"); - } - if let Some(pane_id) = self.rename_pane_target { - assert_live_pane(pane_id, "rename pane target"); - } - if let Some(selection) = &self.selection { - assert_live_pane(selection.pane_id, "text selection"); - } else { - assert!( - self.selection_autoscroll.is_none(), - "selection autoscroll must not remain without an active text selection" - ); - } - if let Some(gesture) = &self.right_click_passthrough { - assert_live_pane(gesture.pane_info.id, "right-click passthrough gesture"); - } - if let Some(drag) = &self.drag { - match &drag.target { - DragTarget::WorkspaceReorder { - source_ws_idx, - drop_target, - .. - } => { - assert_workspace_index(*source_ws_idx, "workspace drag source"); - if let Some(WorkspaceDropTarget::Before(ws_idx)) = drop_target { - assert_workspace_index(*ws_idx, "workspace drag target"); - } - } - DragTarget::TabReorder { - ws_idx, - source_tab_idx, - insert_idx, - .. - } => { - assert_tab_index(*ws_idx, *source_tab_idx, "tab drag source"); - if let Some(insert_idx) = insert_idx { - assert!( - *insert_idx <= self.workspaces[*ws_idx].tabs.len(), - "tab drag insert index {} out of bounds for workspace {} with {} tabs", - insert_idx, - ws_idx, - self.workspaces[*ws_idx].tabs.len() - ); - } - } - DragTarget::PaneScrollbar { pane_id, .. } => { - assert_live_pane(*pane_id, "pane scrollbar drag") - } - _ => {} - } - } - for press in self.workspace_presses.values() { - assert_workspace_index(press.ws_idx, "workspace press"); - } - for press in self.tab_presses.values() { - assert_tab_index(press.ws_idx, press.tab_idx, "tab press"); - } - if let Some(menu) = &self.context_menu { - match menu.kind { - ContextMenuKind::Workspace { ws_idx } - | ContextMenuKind::GitWorkspace { ws_idx, .. } => { - assert_workspace_index(ws_idx, "context menu workspace") - } - ContextMenuKind::Tab { ws_idx, tab_idx } => { - assert_tab_index(ws_idx, tab_idx, "context menu tab") - } - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - source_pane_id, - .. - } => { - assert_tab_index(ws_idx, tab_idx, "context menu pane tab"); - assert!( - self.workspaces[ws_idx].tabs[tab_idx] - .panes - .contains_key(&pane_id), - "context menu pane references pane {:?} outside workspace {} tab {}", - pane_id, - ws_idx, - tab_idx - ); - if let Some(source_pane_id) = source_pane_id { - assert_live_pane(source_pane_id, "context menu source pane"); - } - } - } - } } pub fn insert_test_runtime( @@ -2400,81 +1392,6 @@ mod tests { state.assert_invariants_for_test(); } - fn navigator_row_for_display(is_workspace: bool) -> NavigatorRow { - NavigatorRow { - target: NavigatorTarget::Workspace { ws_idx: 0 }, - depth: if is_workspace { 0 } else { 1 }, - label: String::new(), - meta: String::new(), - status: crate::detect::AgentState::Idle, - seen: true, - is_current: false, - is_workspace, - is_tab: false, - expanded: true, - search_text: String::new(), - matched: true, - } - } - - #[test] - fn navigator_display_lines_separate_workspace_groups() { - let rows = vec![ - navigator_row_for_display(true), - navigator_row_for_display(false), - navigator_row_for_display(true), - navigator_row_for_display(false), - ]; - assert_eq!( - navigator_display_lines(&rows), - vec![ - NavigatorDisplayLine::Row(0), - NavigatorDisplayLine::Row(1), - NavigatorDisplayLine::Spacer, - NavigatorDisplayLine::Row(2), - NavigatorDisplayLine::Row(3), - ] - ); - } - - #[test] - fn navigator_display_lines_have_no_leading_spacer() { - let rows = vec![ - navigator_row_for_display(true), - navigator_row_for_display(false), - ]; - assert_eq!( - navigator_display_lines(&rows), - vec![NavigatorDisplayLine::Row(0), NavigatorDisplayLine::Row(1)] - ); - assert!(navigator_display_lines(&[]).is_empty()); - } - - #[test] - fn navigator_display_index_maps_row_to_line() { - let rows = vec![ - navigator_row_for_display(true), - navigator_row_for_display(false), - navigator_row_for_display(true), - ]; - let lines = navigator_display_lines(&rows); - assert_eq!(navigator_display_index_of_row(&lines, 2), Some(3)); - assert_eq!(navigator_display_index_of_row(&lines, 9), None); - } - - #[test] - fn navigator_first_row_skips_spacer_lines() { - let rows = vec![ - navigator_row_for_display(true), - navigator_row_for_display(false), - navigator_row_for_display(true), - ]; - let lines = navigator_display_lines(&rows); - // Line 2 is the spacer before the second workspace. - assert_eq!(navigator_first_row_at_or_after(&lines, 2), Some(2)); - assert_eq!(navigator_first_row_at_or_after(&lines, 4), None); - } - fn rgb_luminance(color: Color) -> f64 { let Color::Rgb(r, g, b) = color else { panic!("expected RGB color, got {color:?}"); @@ -2501,7 +1418,7 @@ mod tests { #[test] fn built_in_theme_names_resolve() { - for name in THEME_NAMES { + for name in crate::config::THEME_NAMES { assert!( Palette::from_name(name).is_some(), "theme should resolve: {name}" @@ -2511,7 +1428,7 @@ mod tests { #[test] fn built_in_active_rows_remain_visible_with_matching_terminal_backgrounds() { - for name in THEME_NAMES + for name in crate::config::THEME_NAMES .iter() .copied() .filter(|name| *name != "terminal") @@ -2533,7 +1450,7 @@ mod tests { #[test] fn built_in_selection_rows_stay_distinct_from_background_and_active_rows() { - for name in THEME_NAMES + for name in crate::config::THEME_NAMES .iter() .copied() .filter(|name| *name != "terminal") @@ -2559,7 +1476,7 @@ mod tests { #[test] fn built_in_themes_leave_sidebar_background_unset() { - for name in THEME_NAMES { + for name in crate::config::THEME_NAMES { let palette = Palette::from_name(name).unwrap(); assert_eq!( palette.sidebar_bg, @@ -2620,70 +1537,4 @@ mod tests { KeyModifiers::SHIFT, )); } - - #[test] - fn linked_worktree_context_menu_keeps_safe_close_and_explicit_remove() { - let menu = ContextMenuState { - kind: ContextMenuKind::GitWorkspace { - ws_idx: 0, - is_linked_worktree: true, - has_worktree_children: false, - collapsed: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - - assert_eq!( - menu.items(), - &["Rename", "Close", "Delete worktree checkout..."] - ); - } - - #[test] - fn git_workspace_context_menu_keeps_remove_for_managed_worktrees_only() { - let menu = ContextMenuState { - kind: ContextMenuKind::GitWorkspace { - ws_idx: 0, - is_linked_worktree: false, - has_worktree_children: false, - collapsed: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - - assert_eq!( - menu.items(), - &["Rename", "Close", "New worktree", "Open worktree..."] - ); - } - - #[test] - fn parent_worktree_context_menu_uses_repo_actions() { - let menu = ContextMenuState { - kind: ContextMenuKind::GitWorkspace { - ws_idx: 0, - is_linked_worktree: false, - has_worktree_children: true, - collapsed: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - - assert_eq!( - menu.items(), - &[ - "Rename", - "Close group", - "New worktree", - "Open worktree...", - "Collapse" - ] - ); - } } diff --git a/src/app/tab_bar_status.rs b/src/app/tab_bar_status.rs index eca5a314..596c2117 100644 --- a/src/app/tab_bar_status.rs +++ b/src/app/tab_bar_status.rs @@ -524,7 +524,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs index 0ed2a0f7..94932f4a 100644 --- a/src/app/terminal_titles.rs +++ b/src/app/terminal_titles.rs @@ -88,7 +88,13 @@ mod tests { async fn sync_keeps_latest_raw_title_and_emits_only_for_stripped_changes() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one")]; app.state.active = Some(0); app.state.ensure_test_terminals(); @@ -161,7 +167,13 @@ mod tests { async fn syncing_pending_titles_preserves_sidebar_render_impact() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.workspaces = vec![Workspace::test_new("one")]; app.state.active = Some(0); app.state.ensure_test_terminals(); @@ -189,7 +201,13 @@ mod tests { fn sidebar_redraws_only_for_the_configured_title_form() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; app.state.sidebar_agents.rows_by_agent.insert( "claude".into(), diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs index e0e80003..ed0f632d 100644 --- a/src/app/theme_sync.rs +++ b/src/app/theme_sync.rs @@ -1,51 +1,6 @@ use super::App; impl App { - pub(super) fn update_host_terminal_theme( - &mut self, - kind: crate::terminal_theme::DefaultColorKind, - color: crate::terminal_theme::RgbColor, - ) -> bool { - let mut changed = false; - if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) - && !self.state.host_terminal_appearance_explicit - { - changed |= self.set_host_terminal_appearance(color.inferred_appearance(), false); - } - let next_theme = self.state.host_terminal_theme.with_color(kind, color); - changed | self.set_host_terminal_theme(next_theme) - } - - pub(super) fn update_host_terminal_palette_colors( - &mut self, - colors: &[(u8, crate::terminal_theme::RgbColor)], - ) -> bool { - let mut next_theme = self.state.host_terminal_theme; - for &(index, color) in colors { - next_theme = next_theme.with_palette_color(index, color); - } - self.set_host_terminal_theme(next_theme) - } - - pub(super) fn set_host_terminal_appearance( - &mut self, - appearance: crate::terminal_theme::HostAppearance, - explicit: bool, - ) -> bool { - if self.state.host_terminal_appearance == Some(appearance) - && self.state.host_terminal_appearance_explicit == explicit - { - return false; - } - if self.state.host_terminal_appearance_explicit && !explicit { - return false; - } - self.state.host_terminal_appearance = Some(appearance); - self.state.host_terminal_appearance_explicit = explicit; - self.apply_host_terminal_appearance_to_panes(); - self.refresh_effective_app_theme() - } - pub(crate) fn set_host_terminal_appearance_state( &mut self, appearance: Option, diff --git a/src/app/window_title.rs b/src/app/window_title.rs index 841c6a92..09d2f850 100644 --- a/src/app/window_title.rs +++ b/src/app/window_title.rs @@ -107,7 +107,13 @@ mod tests { fn test_app() -> App { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.workspaces = vec![Workspace::test_new("herd")]; app.state.active = Some(0); app.state.ensure_test_terminals(); diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index 9993a70e..b4ac1482 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -1,972 +1,6 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -use super::{ - state::{WorktreeCreateState, WorktreeOpenEntry, WorktreeOpenState, WorktreeRemoveState}, - App, Mode, -}; -#[cfg(test)] -use crate::events::AppEvent; -use crate::events::{WorktreeAddResult, WorktreeRemoveResult}; +use super::App; impl App { - fn worktree_source_metadata( - &self, - ws_idx: usize, - ) -> Result< - ( - Option, - crate::workspace::GitSpaceMetadata, - std::path::PathBuf, - String, - ), - String, - > { - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return Err("Workspace not found.".into()); - }; - let existing_membership = ws.worktree_space().cloned(); - if existing_membership - .as_ref() - .is_some_and(|membership| membership.is_linked_worktree) - { - return Err( - "New and open worktree actions start from the repo parent workspace.".into(), - ); - } - - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - if git_space - .as_ref() - .is_some_and(|metadata| metadata.is_linked_worktree) - { - return Err( - "New and open worktree actions start from the repo parent workspace.".into(), - ); - } - - let space = existing_membership - .as_ref() - .map_or(git_space, |membership| { - Some(crate::workspace::GitSpaceMetadata { - key: membership.key.clone(), - checkout_key: membership.checkout_path.display().to_string(), - repo_name: membership.label.clone(), - repo_root: membership.repo_root.clone(), - is_linked_worktree: membership.is_linked_worktree, - }) - }) - .ok_or_else(|| { - "Herdr worktree actions require a workspace inside a Git work tree.".to_string() - })?; - let source_checkout_path = existing_membership - .as_ref() - .map(|membership| membership.checkout_path.clone()) - .unwrap_or_else(|| space.repo_root.clone()); - let source_workspace_id = self.state.workspaces[ws_idx].id.clone(); - Ok(( - existing_membership, - space, - source_checkout_path, - source_workspace_id, - )) - } - - pub(crate) fn open_new_linked_worktree_dialog(&mut self, ws_idx: usize) { - let (existing_membership, space, source_checkout_path, source_workspace_id) = - match self.worktree_source_metadata(ws_idx) { - Ok(metadata) => metadata, - Err(err) => { - self.state.config_diagnostic = Some(err); - return; - } - }; - - let repo_name = space.repo_name.clone(); - let seed = SystemTime::now() - .duration_since(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 checkout_path = crate::worktree::default_checkout_path( - &self.state.worktree_directory, - &repo_name, - &branch, - ); - - tracing::info!( - ws_idx, - repo_root = %space.repo_root.display(), - branch, - checkout_path = %checkout_path.display(), - "opening worktree dialog" - ); - self.state.selected = ws_idx; - self.state.name_input = branch.clone(); - self.state.name_input_replace_on_type = true; - self.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id, - source_checkout_path, - source_existing_membership: existing_membership, - source_repo_root: space.repo_root, - repo_key: space.key, - repo_name, - branch, - checkout_path, - error: None, - creating: false, - }); - self.state.mode = Mode::NewLinkedWorktree; - } - - pub(crate) fn open_remove_linked_worktree_confirmation(&mut self, ws_idx: usize) { - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return; - }; - if !ws - .worktree_space() - .is_some_and(|space| space.is_linked_worktree) - { - self.state.config_diagnostic = - Some("This workspace is not a Herdr-managed worktree checkout.".into()); - return; - } - let Some(space) = ws.worktree_space().cloned() else { - return; - }; - self.state.selected = ws_idx; - self.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: ws.id.clone(), - repo_root: space.repo_root, - path: space.checkout_path, - error: None, - removing: false, - force_confirmation: false, - }); - self.state.mode = Mode::ConfirmRemoveWorktree; - } - - pub(crate) fn open_existing_worktree_dialog(&mut self, ws_idx: usize) { - let (existing_membership, space, source_checkout_path, source_workspace_id) = - match self.worktree_source_metadata(ws_idx) { - Ok(metadata) => metadata, - Err(err) => { - self.state.config_diagnostic = Some(err); - return; - } - }; - - let list = match crate::worktree::list_existing_worktrees(&space.repo_root, false) { - Ok(list) => list, - Err(err) => { - self.state.config_diagnostic = Some(err); - return; - } - }; - let entries = list - .into_iter() - .filter(|entry| !entry.is_bare && !entry.is_prunable) - .map(|entry| { - let entry_checkout_path = crate::worktree::canonical_or_original(&entry.path); - let entry_checkout_key = entry_checkout_path.display().to_string(); - let repo_checkout_path = crate::worktree::canonical_or_original(&space.repo_root); - let already_open_ws_idx = self.state.workspaces.iter().position(|ws| { - if let Some(membership) = ws.worktree_space() { - return crate::worktree::canonical_or_original(&membership.checkout_path) - == entry_checkout_path; - } - - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from( - &self.state.terminals, - &self.terminal_runtimes, - ) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - if git_space - .as_ref() - .is_some_and(|metadata| metadata.checkout_key == entry_checkout_key) - { - return true; - } - - ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) - .as_deref() - .is_some_and(|cwd| { - crate::worktree::canonical_or_original(cwd) == entry_checkout_path - }) - }); - WorktreeOpenEntry { - is_linked_worktree: entry_checkout_path != repo_checkout_path, - path: entry.path, - branch: entry.branch, - already_open_ws_idx, - } - }) - .collect::>(); - - if entries.is_empty() { - self.state.config_diagnostic = Some("No Git worktrees found for this repo.".into()); - return; - } - - self.state.selected = ws_idx; - self.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership: existing_membership, - source_checkout_path, - source_repo_root: space.repo_root, - repo_key: space.key, - repo_name: space.repo_name, - entries, - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - self.state.mode = Mode::OpenExistingWorktree; - } - - pub(crate) fn handle_worktree_create_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - if self - .state - .worktree_create - .as_ref() - .is_some_and(|create| create.creating) - { - return; - } - self.close_worktree_create_dialog(); - } - KeyCode::Enter => self.submit_worktree_create_via_api(), - KeyCode::Backspace => { - if self.state.name_input_replace_on_type { - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - } else { - self.state.name_input.pop(); - } - self.sync_worktree_branch_from_input(); - } - KeyCode::Char(c) => { - self.insert_worktree_create_text(&c.to_string()); - } - _ => {} - } - } - - pub(crate) fn insert_worktree_create_text(&mut self, text: &str) { - if self.state.name_input_replace_on_type { - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - } - self.state.name_input.push_str(text); - self.sync_worktree_branch_from_input(); - } - - pub(crate) fn handle_worktree_open_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.worktree_open = None; - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - KeyCode::Up => { - if let Some(open) = &mut self.state.worktree_open { - open.select_previous_filtered(); - } - } - KeyCode::Down => { - if let Some(open) = &mut self.state.worktree_open { - open.select_next_filtered(); - } - } - KeyCode::Char('/') => { - if let Some(open) = &mut self.state.worktree_open { - if open.search_focused { - open.query.push('/'); - open.normalize_selection(); - } else { - open.search_focused = true; - } - } - } - KeyCode::Char(ch) - if self - .state - .worktree_open - .as_ref() - .is_some_and(|open| open.search_focused) - && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) - && !ch.is_control() => - { - self.insert_worktree_open_search_text(&ch.to_string()); - } - KeyCode::Backspace - if self - .state - .worktree_open - .as_ref() - .is_some_and(|open| open.search_focused) => - { - if let Some(open) = &mut self.state.worktree_open { - open.query.pop(); - open.normalize_selection(); - } - } - KeyCode::Enter => self.submit_worktree_open_via_api(), - _ => {} - } - } - - pub(crate) fn insert_worktree_open_search_text(&mut self, text: &str) { - let Some(open) = &mut self.state.worktree_open else { - return; - }; - if !open.search_focused { - return; - } - open.query.push_str(text); - open.normalize_selection(); - } - - #[cfg(test)] - pub(crate) fn open_selected_existing_worktree(&mut self) { - let Some(open) = self.state.worktree_open.as_ref() else { - return; - }; - let Some(entry_idx) = open.selected_entry_index() else { - return; - }; - let Some(entry) = open.entries.get(entry_idx).cloned() else { - return; - }; - let source_workspace_id = open.source_workspace_id.clone(); - let source_existing_membership = open.source_existing_membership.clone(); - let source_checkout_path = open.source_checkout_path.clone(); - let source_repo_root = open.source_repo_root.clone(); - let repo_key = open.repo_key.clone(); - let repo_name = open.repo_name.clone(); - self.state.worktree_open = None; - - if let Some(ws_idx) = self.open_workspace_idx_for_checkout(&entry.path) { - self.mark_opened_existing_worktree_membership( - &source_workspace_id, - source_existing_membership, - source_checkout_path, - source_repo_root, - repo_key, - repo_name, - ws_idx, - entry.path, - entry.is_linked_worktree, - ); - self.state.switch_workspace(ws_idx); - self.state.mode = Mode::Terminal; - self.emit_worktree_opened_for_workspace(ws_idx, true); - return; - } - - if let Some(source_ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == source_workspace_id) - { - let source_membership = source_existing_membership.clone().unwrap_or( - crate::workspace::WorktreeSpaceMembership { - key: repo_key.clone(), - label: repo_name.clone(), - repo_root: source_repo_root.clone(), - checkout_path: source_checkout_path.clone(), - is_linked_worktree: false, - }, - ); - self.set_worktree_membership(source_ws_idx, source_membership, true); - } - - match self.create_workspace_with_options(entry.path.clone(), true) { - Ok(new_ws_idx) => { - self.set_worktree_membership( - new_ws_idx, - crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: entry.path, - is_linked_worktree: entry.is_linked_worktree, - }, - false, - ); - self.emit_workspace_open_events(new_ws_idx); - self.emit_worktree_opened_for_workspace(new_ws_idx, false); - } - Err(err) => { - self.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership, - source_checkout_path, - source_repo_root, - repo_key, - repo_name, - entries: vec![entry], - selected: 0, - query: String::new(), - search_focused: false, - error: Some(format!("failed to open worktree: {err}")), - }); - self.state.mode = Mode::OpenExistingWorktree; - } - } - } - - // The caller has already extracted the open-worktree dialog state; keeping the - // membership fields explicit here avoids borrowing AppState across workspace creation. - #[allow(clippy::too_many_arguments)] - #[cfg(test)] - fn mark_opened_existing_worktree_membership( - &mut self, - source_workspace_id: &str, - source_existing_membership: Option, - source_checkout_path: std::path::PathBuf, - source_repo_root: std::path::PathBuf, - repo_key: String, - repo_name: String, - target_ws_idx: usize, - target_path: std::path::PathBuf, - target_is_linked_worktree: bool, - ) { - if let Some(source_ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == source_workspace_id) - { - let source_membership = - source_existing_membership.unwrap_or(crate::workspace::WorktreeSpaceMembership { - key: repo_key.clone(), - label: repo_name.clone(), - repo_root: source_repo_root.clone(), - checkout_path: source_checkout_path, - is_linked_worktree: false, - }); - self.set_worktree_membership(source_ws_idx, source_membership, true); - } - self.set_worktree_membership( - target_ws_idx, - crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: target_path, - is_linked_worktree: target_is_linked_worktree, - }, - true, - ); - } - - fn close_worktree_create_dialog(&mut self) { - self.state.worktree_create = None; - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - fn sync_worktree_branch_from_input(&mut self) { - let Some(create) = &mut self.state.worktree_create else { - return; - }; - create.branch = self.state.name_input.clone(); - create.checkout_path = crate::worktree::default_checkout_path( - &self.state.worktree_directory, - &create.repo_name, - &create.branch, - ); - create.error = None; - } - - #[cfg(test)] - pub(crate) fn start_worktree_add(&mut self) { - self.sync_worktree_branch_from_input(); - let Some(create) = &mut self.state.worktree_create else { - return; - }; - let branch = create.branch.trim().to_string(); - if branch.is_empty() { - create.error = Some("branch is required".into()); - return; - } - if create.creating { - return; - } - - create.branch = branch.clone(); - self.state.name_input = branch.clone(); - create.checkout_path = crate::worktree::default_checkout_path( - &self.state.worktree_directory, - &create.repo_name, - &branch, - ); - create.creating = true; - create.error = None; - - let parent_dir = create - .checkout_path - .parent() - .map(std::path::Path::to_path_buf); - tracing::info!( - repo_root = %create.source_repo_root.display(), - branch = %create.branch, - checkout_path = %create.checkout_path.display(), - "starting git worktree add" - ); - let path = create.checkout_path.clone(); - let source_checkout_path = create.source_checkout_path.clone(); - let branch = create.branch.clone(); - let event_tx = self.event_tx.clone(); - std::thread::spawn(move || { - let result = if let Some(parent_dir) = parent_dir { - std::fs::create_dir_all(&parent_dir).map_err(|err| err.to_string()) - } else { - Ok(()) - } - .and_then(|()| { - crate::worktree::run_worktree_add_command( - &source_checkout_path, - &path, - &branch, - "HEAD", - false, - ) - }); - let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new( - WorktreeAddResult { - path, - api_request: None, - result, - }, - ))); - }); - } - - pub(crate) fn submit_worktree_create_via_api(&mut self) { - self.sync_worktree_branch_from_input(); - let Some(create) = &mut self.state.worktree_create else { - return; - }; - let branch = create.branch.trim().to_string(); - if branch.is_empty() { - create.error = Some("branch is required".into()); - return; - } - if create.creating { - return; - } - - create.branch = branch.clone(); - self.state.name_input = branch.clone(); - create.checkout_path = crate::worktree::default_checkout_path( - &self.state.worktree_directory, - &create.repo_name, - &branch, - ); - create.creating = true; - create.error = None; - let workspace_id = create.source_workspace_id.clone(); - let checkout_path = create.checkout_path.display().to_string(); - - let immediate_response = self.runtime_worktree_create_deferred( - "tui.worktree.create", - crate::api::schema::WorktreeCreateParams { - workspace_id: Some(workspace_id), - cwd: None, - branch: Some(branch), - path: Some(checkout_path), - base: Some("HEAD".into()), - focus: true, - label: None, - trust_repository: false, - }, - ); - if let Some(message) = immediate_api_error_message(immediate_response.as_deref()) { - if let Some(create) = &mut self.state.worktree_create { - create.creating = false; - create.error = Some(message); - } - } - } - - pub(crate) fn handle_worktree_remove_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - if self - .state - .worktree_remove - .as_ref() - .is_some_and(|remove| remove.removing) - { - return; - } - self.state.worktree_remove = None; - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - KeyCode::Enter => self.submit_worktree_remove_via_api(), - _ => {} - } - } - - #[cfg(test)] - pub(crate) fn start_worktree_remove(&mut self) { - let Some((workspace_id, repo_root, path, force)) = - self.state.worktree_remove.as_mut().and_then(|remove| { - if remove.removing { - return None; - } - #[cfg(windows)] - if !remove.force_confirmation - && crate::worktree::checkout_has_dirty_files(&remove.path, false) - .unwrap_or(false) - { - remove.force_confirmation = true; - remove.error = None; - return None; - } - remove.removing = true; - remove.error = None; - Some(( - remove.workspace_id.clone(), - remove.repo_root.clone(), - remove.path.clone(), - remove.force_confirmation, - )) - }) - else { - return; - }; - - if Self::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(force) { - if let Some(ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == workspace_id) - { - self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx); - } - } - - let (workspace_snapshot, worktree_snapshot) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == workspace_id) - .map(|ws_idx| { - let workspace = Box::new(self.workspace_info(ws_idx)); - let worktree = self.state.workspaces[ws_idx] - .worktree_space() - .cloned() - .map(|space| Box::new(self.worktree_info_for_membership(&space, None))); - (Some(workspace), worktree) - }) - .unwrap_or((None, None)); - - let command = - crate::worktree::build_worktree_remove_command(&repo_root, &path, force, false); - tracing::info!(workspace_id = %workspace_id, path = %path.display(), force, "starting git worktree remove"); - let event_tx = self.event_tx.clone(); - std::thread::spawn(move || { - let result = crate::worktree::run_worktree_remove_command_with_recovery( - &command, &repo_root, &path, force, false, - ); - let _ = event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(Box::new( - WorktreeRemoveResult { - workspace_id, - path, - workspace: workspace_snapshot, - worktree: worktree_snapshot, - forced: force, - api_request: None, - result, - }, - ))); - }); - } - - pub(crate) fn submit_worktree_open_via_api(&mut self) { - let Some(open) = self.state.worktree_open.as_ref() else { - return; - }; - let Some(entry_idx) = open.selected_entry_index() else { - return; - }; - let Some(entry) = open.entries.get(entry_idx).cloned() else { - return; - }; - let source_workspace_id = open.source_workspace_id.clone(); - - let response = self.runtime_worktree_open( - "tui.worktree.open", - crate::api::schema::WorktreeOpenParams { - workspace_id: Some(source_workspace_id), - cwd: None, - path: Some(entry.path.display().to_string()), - branch: None, - focus: true, - label: None, - trust_repository: false, - }, - ); - if serde_json::from_str::(&response).is_ok() { - self.state.worktree_open = None; - self.state.mode = Mode::Terminal; - } else if let Ok(error) = - serde_json::from_str::(&response) - { - if let Some(open) = &mut self.state.worktree_open { - open.error = Some(error.error.message); - } - } - } - - pub(crate) fn submit_worktree_remove_via_api(&mut self) { - let Some(remove) = self.state.worktree_remove.as_mut() else { - return; - }; - if remove.removing { - return; - } - #[cfg(windows)] - if !remove.force_confirmation - && crate::worktree::checkout_has_dirty_files(&remove.path, false).unwrap_or(false) - { - remove.force_confirmation = true; - remove.error = None; - return; - } - - remove.removing = true; - remove.error = None; - let workspace_id = remove.workspace_id.clone(); - let force = remove.force_confirmation; - let immediate_response = self.runtime_worktree_remove_deferred( - "tui.worktree.remove", - crate::api::schema::WorktreeRemoveParams { - workspace_id, - force, - trust_repository: false, - }, - ); - if let Some(message) = immediate_api_error_message(immediate_response.as_deref()) { - if let Some(remove) = &mut self.state.worktree_remove { - remove.removing = false; - remove.error = Some(message); - } - } - } - - pub(crate) fn handle_worktree_add_finished(&mut self, result: WorktreeAddResult) { - if result.api_request.is_some() { - self.handle_api_worktree_add_finished(result); - return; - } - let Some(create) = &mut self.state.worktree_create else { - return; - }; - if create.checkout_path != result.path { - return; - } - - match result.result { - Ok(()) => { - tracing::info!(checkout_path = %create.checkout_path.display(), "git worktree add completed"); - let path = create.checkout_path.clone(); - let source_workspace_id = create.source_workspace_id.clone(); - let source_checkout_path = create.source_checkout_path.clone(); - let source_existing_membership = create.source_existing_membership.clone(); - let repo_key = create.repo_key.clone(); - let repo_name = create.repo_name.clone(); - let source_repo_root = create.source_repo_root.clone(); - self.state.worktree_create = None; - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - let source_membership = source_existing_membership.unwrap_or( - crate::workspace::WorktreeSpaceMembership { - key: repo_key.clone(), - label: repo_name.clone(), - repo_root: source_repo_root.clone(), - checkout_path: source_checkout_path, - is_linked_worktree: false, - }, - ); - if let Some(source_ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == source_workspace_id) - { - self.set_worktree_membership(source_ws_idx, source_membership, true); - } - if let Some(ws_idx) = self.open_workspace_idx_for_checkout(&path) { - self.set_worktree_membership( - ws_idx, - crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: path, - is_linked_worktree: true, - }, - true, - ); - self.state.switch_workspace(ws_idx); - self.state.mode = Mode::Terminal; - if let Some(worktree) = self.worktree_info_for_workspace(ws_idx) { - self.emit_worktree_created_event(ws_idx, worktree); - } - } else { - match self.create_workspace_with_options(path.clone(), true) { - Ok(ws_idx) => { - self.set_worktree_membership( - ws_idx, - crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: path, - is_linked_worktree: true, - }, - false, - ); - self.emit_workspace_open_events(ws_idx); - if let Some(worktree) = self.worktree_info_for_workspace(ws_idx) { - self.emit_worktree_created_event(ws_idx, worktree); - } - } - Err(err) => { - self.state.config_diagnostic = Some(format!( - "created worktree but failed to open workspace: {err}" - )); - self.state.mode = Mode::Navigate; - } - } - } - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - Err(message) => { - tracing::warn!(checkout_path = %create.checkout_path.display(), error = %message, "git worktree add failed"); - create.creating = false; - create.error = Some(message); - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - } - } - pub(crate) fn handle_worktree_remove_finished(&mut self, result: WorktreeRemoveResult) { - if result.api_request.is_some() { - self.handle_api_worktree_remove_finished(result); - return; - } - let Some(remove) = &mut self.state.worktree_remove else { - return; - }; - if remove.workspace_id != result.workspace_id || remove.path != result.path { - return; - } - - match result.result { - Ok(()) => { - tracing::info!(workspace_id = %result.workspace_id, path = %result.path.display(), "git worktree remove completed"); - let forced = result.forced; - self.state.worktree_remove = None; - let mut workspace_id = result.workspace_id.clone(); - let mut workspace_snapshot = result.workspace.as_deref().cloned(); - let mut worktree = result.worktree.as_deref().cloned(); - if let Some(ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == result.workspace_id) - { - workspace_id = self.public_workspace_id(ws_idx); - workspace_snapshot.get_or_insert_with(|| self.workspace_info(ws_idx)); - if worktree.is_none() { - worktree = self.state.workspaces[ws_idx] - .worktree_space() - .cloned() - .map(|space| self.worktree_info_for_membership(&space, None)); - } - let still_same_linked_worktree = self.state.workspaces[ws_idx] - .worktree_space() - .is_some_and(|space| { - space.is_linked_worktree && space.checkout_path == result.path - }); - if still_same_linked_worktree { - self.close_removed_linked_worktree_workspace(ws_idx); - self.shutdown_detached_terminal_runtimes(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceClosed, - data: crate::api::schema::EventData::WorkspaceClosed { - workspace_id: workspace_id.clone(), - workspace: workspace_snapshot.clone(), - }, - }); - } - } else if let Some(snapshot) = workspace_snapshot.as_ref() { - workspace_id = snapshot.workspace_id.clone(); - } - if let Some(worktree) = worktree { - self.emit_worktree_removed_event( - workspace_id, - workspace_snapshot, - worktree, - forced, - ); - } - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - Err(message) => { - tracing::warn!(workspace_id = %result.workspace_id, path = %result.path.display(), error = %message, "git worktree remove failed"); - remove.removing = false; - if !remove.force_confirmation - && crate::worktree::is_dirty_worktree_remove_error(&message) - { - remove.force_confirmation = true; - remove.error = None; - } else { - remove.error = Some(message); - } - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - } - } - pub(crate) fn should_shutdown_workspace_terminal_runtimes_for_worktree_remove( force: bool, ) -> bool { @@ -1017,1384 +51,9 @@ impl App { } } -fn immediate_api_error_message(response: Option<&str>) -> Option { - response - .and_then(|response| { - serde_json::from_str::(response).ok() - }) - .map(|response| response.error.message) -} - #[cfg(test)] mod tests { - use super::*; - - fn unique_temp_path(name: &str) -> std::path::PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) - } - - fn run_git(repo: &std::path::Path, args: &[&str]) { - let status = std::process::Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .status() - .unwrap(); - assert!( - status.success(), - "git command failed: git -C {} {}", - repo.display(), - args.join(" ") - ); - } - - fn create_committed_repo(name: &str) -> std::path::PathBuf { - let repo = unique_temp_path(name); - std::fs::create_dir_all(&repo).unwrap(); - run_git(&repo, &["init", "--quiet"]); - run_git(&repo, &["config", "user.email", "herdr@example.invalid"]); - run_git(&repo, &["config", "user.name", "Herdr Test"]); - std::fs::write(repo.join("README.md"), "test\n").unwrap(); - run_git(&repo, &["add", "README.md"]); - run_git(&repo, &["commit", "--quiet", "-m", "initial"]); - repo - } - - fn wait_for_worktree_event(app: &mut App) -> AppEvent { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while std::time::Instant::now() < deadline { - if let Ok(event) = app.event_rx.try_recv() { - return event; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - panic!("timed out waiting for worktree event"); - } - - fn app_for_worktree_tests() -> App { - app_for_worktree_tests_with_event_hub(crate::api::EventHub::default()) - } - - fn app_for_worktree_tests_with_event_hub(event_hub: crate::api::EventHub) -> App { - App::new( - &crate::config::Config::default(), - true, - None, - tokio::sync::mpsc::unbounded_channel().1, - event_hub, - ) - } - - fn event_kinds(event_hub: &crate::api::EventHub) -> Vec { - event_hub - .events_after(0) - .into_iter() - .map(|(_, event)| event.event) - .collect() - } - - fn shutdown_test_runtimes(app: &mut App) { - for (_, runtime) in app.terminal_runtimes.drain() { - runtime.shutdown(); - } - } - - #[tokio::test] - async fn ui_create_workspace_emits_initial_workspace_tab_and_pane_events() { - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - - app.create_workspace(); - - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - ] - ); - shutdown_test_runtimes(&mut app); - } - - #[tokio::test] - async fn ui_create_tab_emits_tab_and_pane_events() { - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.create_workspace_with_options(std::env::temp_dir(), true) - .unwrap(); - - app.create_tab(); - - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - ] - ); - shutdown_test_runtimes(&mut app); - } - - #[test] - fn worktree_create_replaces_prefilled_branch_on_paste_and_syncs_state() { - let mut app = app_for_worktree_tests(); - app.state.name_input = "generated-branch".into(); - app.state.name_input_replace_on_type = true; - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "generated-branch".into(), - checkout_path: "/repo/herdr-generated-branch".into(), - error: None, - creating: false, - }); - - app.insert_worktree_create_text("feature/linear-302"); - - assert_eq!(app.state.name_input, "feature/linear-302"); - assert!(!app.state.name_input_replace_on_type); - assert_eq!( - app.state - .worktree_create - .as_ref() - .map(|create| create.branch.as_str()), - Some("feature/linear-302") - ); - } - - #[test] - fn worktree_open_search_accepts_pasted_text_when_focused() { - let mut app = app_for_worktree_tests(); - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id: "source".into(), - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![ - WorktreeOpenEntry { - path: "/repo/herdr-main".into(), - branch: Some("main".into()), - is_linked_worktree: false, - already_open_ws_idx: None, - }, - WorktreeOpenEntry { - path: "/repo/feature-linear-302".into(), - branch: Some("feature/linear-302".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }, - ], - selected: 0, - query: String::new(), - search_focused: true, - error: None, - }); - - app.insert_worktree_open_search_text("linear-302"); - - let open = app.state.worktree_open.as_ref().unwrap(); - assert_eq!(open.query, "linear-302"); - assert_eq!(open.selected_entry_index(), Some(1)); - } - - #[test] - fn worktree_open_search_ignores_paste_when_search_is_not_focused() { - let mut app = app_for_worktree_tests(); - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id: "source".into(), - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: Vec::new(), - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.insert_worktree_open_search_text("linear-302"); - - assert_eq!( - app.state - .worktree_open - .as_ref() - .map(|open| open.query.as_str()), - Some("") - ); - } - - #[test] - fn open_selected_existing_worktree_focuses_already_open_workspace() { - let mut app = app_for_worktree_tests(); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("main"), - crate::workspace::Workspace::test_new("issue"), - ]; - app.state.workspaces[1].identity_cwd = "/repo/herdr-issue".into(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id: app.state.workspaces[0].id.clone(), - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![WorktreeOpenEntry { - path: "/repo/herdr-issue".into(), - branch: Some("worktree/issue".into()), - is_linked_worktree: true, - already_open_ws_idx: Some(1), - }], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.open_selected_existing_worktree(); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert!(app.state.worktree_open.is_none()); - assert!(app.state.workspaces[0].worktree_space().is_some()); - let target_membership = app.state.workspaces[1].worktree_space().unwrap(); - assert_eq!(target_membership.key, "repo-key"); - assert_eq!( - target_membership.checkout_path, - std::path::PathBuf::from("/repo/herdr-issue") - ); - assert!(target_membership.is_linked_worktree); - } - - #[tokio::test] - async fn ui_worktree_open_new_workspace_emits_api_parity_events() { - let checkout = unique_temp_path("app-worktree-open-event-checkout"); - std::fs::create_dir_all(&checkout).unwrap(); - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - let source_membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }; - app.state.workspaces[0].worktree_space = Some(source_membership.clone()); - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership: Some(source_membership), - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![WorktreeOpenEntry { - path: checkout.clone(), - branch: Some("worktree/open-event".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.open_selected_existing_worktree(); - - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - crate::api::schema::EventKind::WorktreeOpened, - ] - ); - shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(checkout); - } - - #[tokio::test] - async fn open_selected_existing_worktree_recomputes_stale_already_open_state() { - let checkout = unique_temp_path("app-worktree-stale-open-checkout"); - std::fs::create_dir_all(&checkout).unwrap(); - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("source"), - crate::workspace::Workspace::test_new("other"), - ]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![WorktreeOpenEntry { - path: checkout.clone(), - branch: Some("worktree/stale-open".into()), - is_linked_worktree: true, - already_open_ws_idx: Some(1), - }], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.open_selected_existing_worktree(); - - assert_eq!(app.state.workspaces.len(), 3); - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceUpdated, - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - crate::api::schema::EventKind::WorktreeOpened, - ] - ); - let opened = event_hub - .events_after(0) - .into_iter() - .find_map(|(_, event)| match event.data { - crate::api::schema::EventData::WorktreeOpened { already_open, .. } => { - Some(already_open) - } - _ => None, - }) - .expect("worktree.opened should be emitted"); - assert!(!opened); - shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(checkout); - } - - #[test] - fn worktree_open_search_filters_entries() { - let mut app = app_for_worktree_tests(); - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id: "source".into(), - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![ - WorktreeOpenEntry { - path: "/repo/herdr".into(), - branch: Some("main".into()), - is_linked_worktree: false, - already_open_ws_idx: Some(0), - }, - WorktreeOpenEntry { - path: "/repo/fd-cleanup".into(), - branch: Some("fd-cleanup".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }, - WorktreeOpenEntry { - path: "/repo/bell-forward-macos-bounce".into(), - branch: Some("bell-forward-macos-bounce".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }, - ], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('/'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('f'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('d'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('-'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('c'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('l'), - crossterm::event::KeyModifiers::empty(), - )); - - let open = app.state.worktree_open.as_ref().unwrap(); - assert_eq!(open.query, "fd-cl"); - assert_eq!(open.filtered_indices(), vec![1]); - assert_eq!(open.selected_entry_index(), Some(1)); - } - - #[test] - fn open_existing_worktree_detects_already_open_checkout_from_subdirectory() { - let repo = create_committed_repo("app-worktree-open-existing-repo"); - let checkout = unique_temp_path("app-worktree-open-existing-checkout"); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - "worktree/open-existing", - checkout.to_str().unwrap(), - "HEAD", - ], - ); - let subdir = checkout.join("nested"); - std::fs::create_dir_all(&subdir).unwrap(); - - let mut app = app_for_worktree_tests(); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("main"), - crate::workspace::Workspace::test_new("nested"), - ]; - app.state.workspaces[0].identity_cwd = repo; - app.state.workspaces[1].identity_cwd = subdir; - - app.open_existing_worktree_dialog(0); - - let open = app.state.worktree_open.as_ref().unwrap(); - let checkout = crate::worktree::canonical_or_original(&checkout); - let entry = open - .entries - .iter() - .find(|entry| crate::worktree::canonical_or_original(&entry.path) == checkout) - .unwrap_or_else(|| panic!("missing checkout in entries: {:?}", open.entries)); - assert_eq!(entry.already_open_ws_idx, Some(1)); - } - - #[test] - fn worktree_create_and_open_dialogs_reject_linked_child_source() { - let mut app = app_for_worktree_tests(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; - app.state.mode = Mode::Navigate; - app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - app.open_new_linked_worktree_dialog(0); - - assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.state.worktree_create.is_none()); - assert_eq!( - app.state.config_diagnostic.as_deref(), - Some("New and open worktree actions start from the repo parent workspace.") - ); - - app.state.config_diagnostic = None; - app.open_existing_worktree_dialog(0); - - assert!(app.state.worktree_open.is_none()); - assert_eq!( - app.state.config_diagnostic.as_deref(), - Some("New and open worktree actions start from the repo parent workspace.") - ); - } - - #[test] - fn sync_worktree_branch_updates_derived_path() { - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = std::path::PathBuf::from("/w"); - app.state.name_input = "issue/137".into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: std::path::PathBuf::from("/repo/herdr"), - source_existing_membership: None, - source_repo_root: std::path::PathBuf::from("/repo/herdr"), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "old".into(), - checkout_path: std::path::PathBuf::from("/old"), - error: Some("old error".into()), - creating: false, - }); - - app.sync_worktree_branch_from_input(); - - let create = app.state.worktree_create.unwrap(); - assert_eq!(create.branch, "issue/137"); - assert_eq!( - create.checkout_path, - std::path::PathBuf::from("/w/herdr/issue-137") - ); - assert_eq!(create.error, None); - } - - #[test] - fn worktree_create_enter_submits_through_api_path() { - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = std::path::PathBuf::from("/w"); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - let source_membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }; - let branch = "issue/195"; - let checkout_path = - crate::worktree::default_checkout_path(&app.state.worktree_directory, "herdr", branch); - let checkout_key = crate::worktree::canonical_or_original(&checkout_path); - app.pending_api_worktree_creates.insert(checkout_key, 1); - app.state.workspaces[0].worktree_space = Some(source_membership.clone()); - app.state.mode = Mode::NewLinkedWorktree; - app.state.name_input = branch.into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id, - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: Some(source_membership), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path, - error: None, - creating: false, - }); - - app.handle_worktree_create_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - let create = app.state.worktree_create.as_ref().unwrap(); - assert!(!create.creating); - assert_eq!( - create.error.as_deref(), - Some("worktree operation is already in progress for this checkout") - ); - } - - #[tokio::test] - async fn worktree_open_enter_submits_through_api_path() { - let repo = create_committed_repo("app-worktree-open-enter-repo"); - let checkout = unique_temp_path("app-worktree-open-enter-checkout"); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - "worktree/open-enter", - checkout.to_str().unwrap(), - "HEAD", - ], - ); - - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - let source_membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: repo.clone(), - checkout_path: repo.clone(), - is_linked_worktree: false, - }; - app.state.workspaces[0].worktree_space = Some(source_membership.clone()); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership: Some(source_membership), - source_checkout_path: repo.clone(), - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![WorktreeOpenEntry { - path: checkout.clone(), - branch: Some("worktree/open-enter".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.handle_worktree_open_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert!(app.state.worktree_open.is_none()); - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - crate::api::schema::EventKind::WorktreeOpened, - ] - ); - shutdown_test_runtimes(&mut app); - run_git( - &repo, - &["worktree", "remove", "--force", checkout.to_str().unwrap()], - ); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn worktree_remove_enter_submits_through_api_path() { - let mut app = app_for_worktree_tests(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; - let workspace_id = app.state.workspaces[0].id.clone(); - app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - app.open_remove_linked_worktree_confirmation(0); - app.pending_api_worktree_removes - .insert(workspace_id.clone(), 1); - - app.handle_worktree_remove_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - let remove = app.state.worktree_remove.as_ref().unwrap(); - assert_eq!(remove.workspace_id, workspace_id); - assert!(!remove.removing); - assert_eq!( - remove.error.as_deref(), - Some("worktree operation is already in progress for this checkout") - ); - } - - #[tokio::test] - async fn ui_worktree_create_emits_api_parity_events_after_membership_is_committed() { - let repo = create_committed_repo("app-worktree-create-event-repo"); - let worktree_root = unique_temp_path("app-worktree-create-event-root"); - let branch = "worktree/ui-create-event"; - let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - branch, - checkout.to_str().unwrap(), - "HEAD", - ], - ); - - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - let source_membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: repo.clone(), - checkout_path: repo.clone(), - is_linked_worktree: false, - }; - app.state.workspaces[0].worktree_space = Some(source_membership.clone()); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id, - source_checkout_path: repo.clone(), - source_existing_membership: Some(source_membership), - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: checkout.clone(), - error: None, - creating: true, - }); - let plugin_root = unique_temp_path("app-worktree-create-plugin"); - std::fs::create_dir_all(&plugin_root).unwrap(); - let manifest_path = plugin_root.join("herdr-plugin.toml"); - std::fs::write(&manifest_path, "id = 'example.ui-worktree-create'\n").unwrap(); - app.state.installed_plugins.insert( - "example.ui-worktree-create".into(), - crate::api::schema::InstalledPluginInfo { - plugin_id: "example.ui-worktree-create".into(), - name: "UI Worktree Create".into(), - version: "0.1.0".into(), - min_herdr_version: "0.7.0".into(), - description: None, - manifest_path: manifest_path.display().to_string(), - plugin_root: plugin_root.display().to_string(), - enabled: true, - platforms: None, - build: Vec::new(), - startup: Vec::new(), - actions: Vec::new(), - events: vec![crate::api::schema::PluginManifestEventHook { - on: "worktree.created".into(), - platforms: None, - command: vec!["sh".into(), "-c".into(), "true".into()], - }], - panes: Vec::new(), - link_handlers: Vec::new(), - source: crate::api::schema::PluginSourceInfo::default(), - warnings: Vec::new(), - }, - ); - - app.handle_worktree_add_finished(WorktreeAddResult { - path: checkout.clone(), - api_request: None, - result: Ok(()), - }); - - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - crate::api::schema::EventKind::WorktreeCreated, - ] - ); - let events = event_hub.events_after(0); - let workspace_created = events - .iter() - .find(|(_, event)| event.event == crate::api::schema::EventKind::WorkspaceCreated) - .map(|(_, event)| event) - .expect("workspace.created should be emitted"); - let crate::api::schema::EventData::WorkspaceCreated { workspace } = &workspace_created.data - else { - panic!("unexpected event data"); - }; - let checkout_path = checkout.display().to_string(); - assert_eq!( - workspace - .worktree - .as_ref() - .map(|worktree| worktree.checkout_path.as_str()), - Some(checkout_path.as_str()) - ); - let worktree_created = events - .iter() - .find(|(_, event)| event.event == crate::api::schema::EventKind::WorktreeCreated) - .map(|(_, event)| event) - .expect("worktree.created should be emitted"); - let crate::api::schema::EventData::WorktreeCreated { - workspace, - worktree, - } = &worktree_created.data - else { - panic!("unexpected event data"); - }; - assert_eq!( - workspace - .worktree - .as_ref() - .map(|worktree| worktree.checkout_path.as_str()), - Some(checkout_path.as_str()) - ); - assert_eq!( - worktree.open_workspace_id.as_deref(), - Some(workspace.workspace_id.as_str()) - ); - assert!(app.state.plugin_command_logs.iter().any(|log| { - log.event.as_deref() == Some("worktree.created") - && log.status == crate::api::schema::PluginCommandStatus::Running - })); - - shutdown_test_runtimes(&mut app); - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); - crate::worktree::run_worktree_command(&remove).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(repo); - let _ = std::fs::remove_dir_all(plugin_root); - } - - #[test] - fn worktree_create_finished_reuses_checkout_opened_before_result() { - let checkout = unique_temp_path("app-worktree-create-race-checkout"); - std::fs::create_dir_all(&checkout).unwrap(); - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("source"), - crate::workspace::Workspace::test_new("opened-by-race"), - ]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - app.state.workspaces[1].identity_cwd = checkout.clone(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id, - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "worktree/create-race".into(), - checkout_path: checkout.clone(), - error: None, - creating: true, - }); - - app.handle_worktree_add_finished(WorktreeAddResult { - path: checkout.clone(), - api_request: None, - result: Ok(()), - }); - - assert_eq!(app.state.workspaces.len(), 2); - let kinds = event_kinds(&event_hub); - assert!(!kinds.contains(&crate::api::schema::EventKind::WorkspaceCreated)); - assert_eq!( - kinds - .iter() - .filter(|kind| **kind == crate::api::schema::EventKind::WorktreeCreated) - .count(), - 1 - ); - assert_eq!( - app.state.workspaces[1] - .worktree_space() - .map(|membership| membership.checkout_path.as_path()), - Some(checkout.as_path()) - ); - shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(checkout); - } - - #[test] - fn start_worktree_add_runs_git_on_worker_and_emits_result() { - let repo = create_committed_repo("app-worktree-add-repo"); - let worktree_root = unique_temp_path("app-worktree-add-root"); - let branch = "worktree/app-worker"; - let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = worktree_root.clone(); - app.state.name_input = branch.into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: repo.clone(), - source_existing_membership: None, - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: checkout.clone(), - error: None, - creating: false, - }); - - app.start_worktree_add(); - - assert!(app - .state - .worktree_create - .as_ref() - .is_some_and(|create| create.creating)); - let event = wait_for_worktree_event(&mut app); - match event { - AppEvent::WorktreeAddFinished(result) => { - let result = *result; - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - } - other => panic!("unexpected event: {other:?}"), - } - assert!(checkout.join("README.md").exists()); - - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); - crate::worktree::run_worktree_command(&remove).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn start_worktree_add_existing_branch_checks_out_branch() { - let repo = create_committed_repo("app-worktree-add-existing-branch-repo"); - let worktree_root = unique_temp_path("app-worktree-add-existing-branch-root"); - let branch = "foo"; - let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); - run_git(&repo, &["branch", branch]); - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = worktree_root.clone(); - app.state.name_input = branch.into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: repo.clone(), - source_existing_membership: None, - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: checkout.clone(), - error: None, - creating: false, - }); - - app.start_worktree_add(); - - assert!(app - .state - .worktree_create - .as_ref() - .is_some_and(|create| create.creating)); - let event = wait_for_worktree_event(&mut app); - match event { - AppEvent::WorktreeAddFinished(result) => { - let result = *result; - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - } - other => panic!("unexpected event: {other:?}"), - } - - assert!(checkout.join("README.md").exists()); - let branch_name = std::process::Command::new("git") - .arg("-C") - .arg(&checkout) - .args(["branch", "--show-current"]) - .output() - .unwrap(); - assert!(branch_name.status.success()); - assert_eq!( - String::from_utf8(branch_name.stdout).unwrap().trim(), - branch - ); - - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); - crate::worktree::run_worktree_command(&remove).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn open_new_worktree_dialog_supports_standalone_bare_repo_source() { - let repo = create_committed_repo("app-worktree-dialog-bare-origin"); - let bare = unique_temp_path("app-worktree-dialog-bare-repo"); - run_git( - &repo, - &["clone", "--quiet", "--bare", ".", bare.to_str().unwrap()], - ); - let worktree_root = unique_temp_path("app-worktree-dialog-bare-root"); - - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = worktree_root.clone(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - app.state.workspaces[0].identity_cwd = bare.clone(); - - app.open_new_linked_worktree_dialog(0); - - assert_eq!(app.state.mode, Mode::NewLinkedWorktree); - assert!(app.state.config_diagnostic.is_none()); - let create = app.state.worktree_create.as_ref().unwrap(); - assert_eq!(create.source_checkout_path, bare); - assert_eq!(create.source_repo_root, create.source_checkout_path); - let source_checkout_path = create.source_checkout_path.clone(); - - let branch = "worktree/from-bare-source"; - let repo_name = create.repo_name.clone(); - let checkout = crate::worktree::default_checkout_path(&worktree_root, &repo_name, branch); - app.state.name_input = branch.into(); - - app.start_worktree_add(); - - let event = wait_for_worktree_event(&mut app); - match event { - AppEvent::WorktreeAddFinished(result) => { - let result = *result; - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - } - other => panic!("unexpected event: {other:?}"), - } - assert!(checkout.join("README.md").exists()); - - let remove_new = crate::worktree::build_worktree_remove_command( - &source_checkout_path, - &checkout, - false, - false, - ); - crate::worktree::run_worktree_command(&remove_new).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(source_checkout_path); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn start_worktree_add_uses_source_checkout_head_as_base() { - let repo = create_committed_repo("app-worktree-add-source-repo"); - let source_checkout = unique_temp_path("app-worktree-add-source-checkout"); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - "worktree/source-base", - source_checkout.to_str().unwrap(), - "HEAD", - ], - ); - std::fs::write(source_checkout.join("SOURCE.md"), "source branch\n").unwrap(); - run_git(&source_checkout, &["add", "SOURCE.md"]); - run_git(&source_checkout, &["commit", "--quiet", "-m", "source"]); - - let worktree_root = unique_temp_path("app-worktree-add-from-source-root"); - let branch = "worktree/from-source"; - let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = worktree_root.clone(); - app.state.name_input = branch.into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: source_checkout.clone(), - source_existing_membership: None, - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: checkout.clone(), - error: None, - creating: false, - }); - - app.start_worktree_add(); - - let event = wait_for_worktree_event(&mut app); - match event { - AppEvent::WorktreeAddFinished(result) => { - let result = *result; - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - } - other => panic!("unexpected event: {other:?}"), - } - assert!(checkout.join("SOURCE.md").exists()); - - let remove_new = - crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); - crate::worktree::run_worktree_command(&remove_new).unwrap(); - let remove_source = - crate::worktree::build_worktree_remove_command(&repo, &source_checkout, false, false); - crate::worktree::run_worktree_command(&remove_source).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn dirty_worktree_remove_failure_requests_force_confirmation() { - let path = std::path::PathBuf::from("/w/herdr/dirty"); - let mut app = app_for_worktree_tests(); - app.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: "ws".into(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - path: path.clone(), - error: None, - removing: true, - force_confirmation: false, - }); - - app.handle_worktree_remove_finished(WorktreeRemoveResult { - workspace_id: "ws".into(), - path, - workspace: None, - worktree: None, - forced: false, - api_request: None, - result: Err( - "fatal: '/w/herdr/dirty' contains modified or untracked files, use --force to delete it" - .into(), - ), - }); - - let remove = app.state.worktree_remove.unwrap(); - assert!(!remove.removing); - assert!(remove.force_confirmation); - assert_eq!(remove.error, None); - } - - #[test] - fn non_dirty_worktree_remove_failure_keeps_error_message() { - let path = std::path::PathBuf::from("/w/herdr/missing"); - let mut app = app_for_worktree_tests(); - app.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: "ws".into(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - path: path.clone(), - error: None, - removing: true, - force_confirmation: false, - }); - - app.handle_worktree_remove_finished(WorktreeRemoveResult { - workspace_id: "ws".into(), - path, - workspace: None, - worktree: None, - forced: false, - api_request: None, - result: Err("fatal: '/w/herdr/missing' is not a working tree".into()), - }); - - let remove = app.state.worktree_remove.unwrap(); - assert!(!remove.removing); - assert!(!remove.force_confirmation); - assert_eq!( - remove.error, - Some("fatal: '/w/herdr/missing' is not a working tree".into()) - ); - } - - #[test] - fn worktree_remove_finished_focuses_parent_workspace() { - let mut app = app_for_worktree_tests(); - let checkout = std::path::PathBuf::from("/repo/herdr-issue"); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("parent"), - crate::workspace::Workspace::test_new("issue"), - crate::workspace::Workspace::test_new("sibling"), - ]; - app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }); - app.state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout.clone(), - is_linked_worktree: true, - }); - app.state.workspaces[2].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-sibling".into(), - is_linked_worktree: true, - }); - let child_id = app.state.workspaces[1].id.clone(); - let parent_id = app.state.workspaces[0].id.clone(); - app.state.active = Some(1); - app.state.selected = 1; - app.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: child_id.clone(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - path: checkout.clone(), - error: None, - removing: true, - force_confirmation: false, - }); - - app.handle_worktree_remove_finished(WorktreeRemoveResult { - workspace_id: child_id, - path: checkout, - workspace: None, - worktree: None, - forced: false, - api_request: None, - result: Ok(()), - }); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.workspaces[0].id, parent_id); - assert_eq!(app.state.workspaces[1].display_name(), "sibling"); - assert!(app.state.worktree_remove.is_none()); - } - - #[test] - fn worktree_remove_finished_emits_removed_event_from_snapshot_after_workspace_closed() { - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; - let internal_workspace_id = app.state.workspaces[0].id.clone(); - let checkout = std::path::PathBuf::from("/repo/herdr-issue"); - app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout.clone(), - is_linked_worktree: true, - }); - let workspace_snapshot = app.workspace_info(0); - let worktree_snapshot = crate::api::schema::WorktreeInfo { - path: checkout.display().to_string(), - branch: Some("worktree/issue".into()), - is_bare: false, - is_detached: false, - is_prunable: false, - is_linked_worktree: true, - open_workspace_id: None, - label: "herdr".into(), - }; - app.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: internal_workspace_id.clone(), - repo_root: "/repo/herdr".into(), - path: checkout.clone(), - error: None, - removing: true, - force_confirmation: true, - }); - app.state.workspaces.clear(); - - app.handle_worktree_remove_finished(WorktreeRemoveResult { - workspace_id: internal_workspace_id, - path: checkout, - workspace: Some(Box::new(workspace_snapshot.clone())), - worktree: Some(Box::new(worktree_snapshot)), - forced: true, - api_request: None, - result: Ok(()), - }); - - assert_eq!( - event_kinds(&event_hub), - vec![crate::api::schema::EventKind::WorktreeRemoved] - ); - assert!(event_hub.events_after(0).iter().any(|(_, event)| { - matches!( - &event.data, - crate::api::schema::EventData::WorktreeRemoved { - workspace_id, - workspace: Some(workspace), - worktree, - forced, - } if workspace_id == &workspace_snapshot.workspace_id - && workspace.workspace_id == workspace_snapshot.workspace_id - && worktree.branch.as_deref() == Some("worktree/issue") - && *forced - ) - })); - } - - #[test] - fn dirty_worktree_remove_retries_with_force_and_closes_workspace() { - let repo = create_committed_repo("app-worktree-dirty-remove-repo"); - let checkout = unique_temp_path("app-worktree-dirty-remove-checkout"); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - "worktree/dirty-remove", - checkout.to_str().unwrap(), - "HEAD", - ], - ); - std::fs::write(checkout.join("README.md"), "dirty\n").unwrap(); - - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; - let workspace_id = app.state.workspaces[0].id.clone(); - app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: repo.clone(), - checkout_path: checkout.clone(), - is_linked_worktree: true, - }); - app.state.active = Some(0); - app.state.selected = 0; - app.open_remove_linked_worktree_confirmation(0); - - app.start_worktree_remove(); - - #[cfg(not(windows))] - { - let safe_event = wait_for_worktree_event(&mut app); - match safe_event { - AppEvent::WorktreeRemoveFinished(result) => { - let result = *result; - assert_eq!(result.workspace_id, workspace_id); - assert_eq!(result.path, checkout); - assert!(result.result.is_err()); - app.handle_worktree_remove_finished(result); - } - other => panic!("unexpected event: {other:?}"), - } - } - - let remove = app.state.worktree_remove.as_ref().unwrap(); - assert!(!remove.removing); - assert!(remove.force_confirmation); - assert!(checkout.exists()); - - app.start_worktree_remove(); - let force_event = wait_for_worktree_event(&mut app); - match force_event { - AppEvent::WorktreeRemoveFinished(result) => { - let result = *result; - assert_eq!(result.workspace_id, workspace_id); - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - app.handle_worktree_remove_finished(result); - } - other => panic!("unexpected event: {other:?}"), - } - - assert!(!checkout.exists()); - assert!(app.state.worktree_remove.is_none()); - assert!(app.state.workspaces.is_empty()); - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceClosed, - crate::api::schema::EventKind::WorktreeRemoved, - ] - ); - assert!(event_hub.events_after(0).iter().any(|(_, event)| { - matches!( - &event.data, - crate::api::schema::EventData::WorktreeRemoved { worktree, .. } - if worktree.branch.as_deref() == Some("worktree/dirty-remove") - && !worktree.is_detached - ) - })); - - let _ = std::fs::remove_dir_all(repo); - } + use super::App; #[test] fn worktree_remove_runtime_shutdown_policy_preserves_windows_safe_remove() { diff --git a/src/client/attach.rs b/src/client/attach.rs new file mode 100644 index 00000000..c32990d1 --- /dev/null +++ b/src/client/attach.rs @@ -0,0 +1,582 @@ +//! Direct terminal attach input parsing and semantic actions. + +#[cfg(unix)] +use std::io; + +#[cfg(unix)] +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; + +#[cfg(unix)] +use super::write_to_server; +#[cfg(unix)] +use crate::ipc::LocalStream; +#[cfg(unix)] +use crate::protocol::{AttachScrollDirection, AttachScrollSource, ClientMessage}; + +#[derive(Debug, Default)] +#[cfg(windows)] +pub(super) struct AttachEscapeState; + +#[derive(Debug, Default)] +#[cfg(unix)] +pub(super) struct AttachEscapeState { + pending_prefix: Option>, +} + +#[derive(Debug)] +#[cfg(unix)] +pub(super) enum AttachInputAction { + Forward(Vec), + ForwardPair(Vec, Vec), + Semantic(AttachSemanticAction), + ForwardThenSemantic(Vec, AttachSemanticAction), + Detach, + None, +} + +#[derive(Debug)] +#[cfg(unix)] +pub(super) enum AttachSemanticAction { + Scroll { + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option, + row: Option, + modifiers: u8, + }, + Mouse { + kind: crate::protocol::ClientMouseKind, + position: crate::protocol::ClientMousePosition, + modifiers: u8, + }, + Ignore, +} + +impl AttachEscapeState { + #[cfg(unix)] + pub(super) fn filter_input( + &mut self, + data: Vec, + viewport_rows: u16, + mouse_scroll_lines: usize, + ) -> AttachInputAction { + const PREFIX: u8 = 0x02; // Ctrl+B + + if crate::raw_input::is_complete_text_bracketed_paste(&data) { + return if let Some(prefix) = self.pending_prefix.take() { + AttachInputAction::ForwardPair(prefix, data) + } else { + AttachInputAction::Forward(data) + }; + } + + if let Some(key) = single_attach_key(&data) { + let is_prefix = key.code == crossterm::event::KeyCode::Char('b') + && key.modifiers == crossterm::event::KeyModifiers::CONTROL; + let is_quit = key.code == crossterm::event::KeyCode::Char('q') + && key.modifiers.is_empty() + && key.kind == crossterm::event::KeyEventKind::Press; + + if let Some(mut prefix) = self.pending_prefix.take() { + if is_prefix && key.kind != crossterm::event::KeyEventKind::Press { + prefix.extend(data); + self.pending_prefix = Some(prefix); + return AttachInputAction::None; + } + if is_quit { + return AttachInputAction::Detach; + } + if is_prefix { + return AttachInputAction::Forward(data); + } + if let Some(action) = attach_scroll_action(&data, viewport_rows, mouse_scroll_lines) + { + return AttachInputAction::ForwardThenSemantic(prefix, action); + } + prefix.extend(data); + return AttachInputAction::Forward(prefix); + } + + if is_prefix && key.kind == crossterm::event::KeyEventKind::Press { + self.pending_prefix = Some(data); + return AttachInputAction::None; + } + } + + if let Some(action) = attach_scroll_action(&data, viewport_rows, mouse_scroll_lines) { + return if let Some(prefix) = self.pending_prefix.take() { + AttachInputAction::ForwardThenSemantic(prefix, action) + } else { + AttachInputAction::Semantic(action) + }; + } + + // The host framer normally supplies one complete event. Preserve the legacy + // byte path for coalesced plain input used by older terminals. + let mut output = Vec::with_capacity(data.len()); + for byte in data { + if let Some(mut prefix) = self.pending_prefix.take() { + match byte { + b'q' => return AttachInputAction::Detach, + PREFIX => output.extend(prefix), + other => { + prefix.push(other); + output.extend(prefix); + } + } + continue; + } + + if byte == PREFIX { + self.pending_prefix = Some(vec![PREFIX]); + } else { + output.push(byte); + } + } + + if output.is_empty() { + AttachInputAction::None + } else if let Some(action) = + attach_scroll_action(&output, viewport_rows, mouse_scroll_lines) + { + AttachInputAction::Semantic(action) + } else { + AttachInputAction::Forward(output) + } + } + + #[cfg(unix)] + pub(super) fn take_pending_prefix(&mut self) -> Option> { + self.pending_prefix.take() + } +} + +#[cfg(unix)] +fn single_attach_key(data: &[u8]) -> Option { + let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); + if events.len() != 1 { + return None; + } + match events.pop()? { + crate::raw_input::RawInputEvent::Key(key) => Some(key), + _ => None, + } +} + +#[cfg(unix)] +pub(super) fn direct_attach_pixel_mouse( + data: &[u8], + geometry: crate::input::mouse::HostGeometry, +) -> Option<( + crate::protocol::ClientMouseKind, + crate::protocol::ClientMousePosition, + u8, +)> { + let (x, y) = crate::input::mouse::parse_report(data)?; + let (column, row) = geometry.cell(x, y)?; + let cell_report = crate::input::mouse::report_at_cell(data, column, row)?; + let mut events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); + if events.len() != 1 { + return None; + } + let crate::raw_input::RawInputEvent::Mouse(mouse) = events.pop()? else { + return None; + }; + Some(( + crate::protocol::ClientMouseKind::from_crossterm(mouse.kind)?, + crate::protocol::ClientMousePosition::Pixels { x, y, column, row }, + mouse.modifiers.bits(), + )) +} + +#[cfg(unix)] +fn attach_scroll_action( + data: &[u8], + viewport_rows: u16, + mouse_scroll_lines: usize, +) -> Option { + let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); + if events.len() != 1 { + return None; + } + + match events.pop()? { + crate::raw_input::RawInputEvent::Mouse(mouse) => match mouse.kind { + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + let direction = if mouse.kind == MouseEventKind::ScrollUp { + AttachScrollDirection::Up + } else { + AttachScrollDirection::Down + }; + Some(AttachSemanticAction::Scroll { + source: AttachScrollSource::Wheel, + direction, + lines: mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, + column: Some(mouse.column), + row: Some(mouse.row), + modifiers: mouse.modifiers.bits(), + }) + } + kind => Some(AttachSemanticAction::Mouse { + kind: crate::protocol::ClientMouseKind::from_crossterm(kind)?, + position: crate::protocol::ClientMousePosition::Cell { + column: mouse.column, + row: mouse.row, + }, + modifiers: mouse.modifiers.bits(), + }), + }, + crate::raw_input::RawInputEvent::Key(key) + if key.modifiers.is_empty() + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => + { + let direction = match key.code { + KeyCode::PageUp => AttachScrollDirection::Up, + KeyCode::PageDown => AttachScrollDirection::Down, + _ => return None, + }; + Some(AttachSemanticAction::Scroll { + source: AttachScrollSource::PageKey { + input: data.to_vec(), + }, + direction, + lines: viewport_rows.saturating_sub(1).max(1), + column: None, + row: None, + modifiers: KeyModifiers::empty().bits(), + }) + } + crate::raw_input::RawInputEvent::Key(key) + if key.modifiers.is_empty() + && key.kind == KeyEventKind::Release + && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) => + { + Some(AttachSemanticAction::Ignore) + } + _ => None, + } +} + +#[cfg(unix)] +pub(super) fn write_attach_semantic_action( + stream: &mut LocalStream, + action: AttachSemanticAction, +) -> io::Result<()> { + let message = match action { + AttachSemanticAction::Scroll { + source, + direction, + lines, + column, + row, + modifiers, + } => ClientMessage::AttachScroll { + source, + direction, + lines, + column, + row, + modifiers, + }, + AttachSemanticAction::Mouse { + kind, + position, + modifiers, + } => ClientMessage::AttachMouse { + kind, + position, + geometry: None, + modifiers, + lines: 1, + }, + AttachSemanticAction::Ignore => return Ok(()), + }; + write_to_server(stream, &message) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{AttachScrollDirection, AttachScrollSource}; + + #[cfg(unix)] + #[test] + fn attach_escape_detaches_on_prefix_q() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(vec![b'q'], 24, 3), + AttachInputAction::Detach + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_sends_literal_prefix_on_double_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + match escape.filter_input(vec![0x02], 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]), + other => panic!("expected forwarded prefix, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_detaches_on_kitty_encoded_prefix_q() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"\x1b[98;5:3u".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"\x1b[113u".to_vec(), 24, 3), + AttachInputAction::Detach + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_detaches_on_modify_other_keys_encoded_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[27;5;98~".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"q".to_vec(), 24, 3), + AttachInputAction::Detach + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_forwards_kitty_encoded_literal_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"\x1b[98;5:3u".to_vec(), 24, 3), + AttachInputAction::None + )); + match escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[98;5u"), + other => panic!("expected Kitty-encoded prefix, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_does_not_interpret_bracketed_paste_contents() { + let mut escape = AttachEscapeState::default(); + let paste = b"\x1b[200~one\x02q\ntwo\x1b[201~".to_vec(); + + match escape.filter_input(paste.clone(), 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, paste), + other => panic!("expected opaque paste, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_flushes_pending_prefix_before_bracketed_paste() { + let mut escape = AttachEscapeState::default(); + let paste = b"\x1b[200~one\ntwo\x1b[201~".to_vec(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert!(matches!( + escape.filter_input(paste.clone(), 24, 3), + AttachInputAction::ForwardPair(prefix, bytes) + if prefix == vec![0x02] && bytes == paste + )); + assert!(matches!( + escape.filter_input(vec![b'q'], 24, 3), + AttachInputAction::Forward(bytes) if bytes == b"q" + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_forwards_prefix_before_non_escape_key() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![b'a', 0x02], 24, 3), + AttachInputAction::Forward(bytes) if bytes == b"a" + )); + match escape.filter_input(vec![b'x'], 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']), + other => panic!("expected forwarded bytes, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_turns_wheel_into_scroll_action() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[<64;11;6M".to_vec(), 24, 7) { + AttachInputAction::Semantic(AttachSemanticAction::Scroll { + source, + direction, + lines, + column, + row, + .. + }) => { + assert_eq!(source, AttachScrollSource::Wheel); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 7); + assert_eq!(column, Some(10)); + assert_eq!(row, Some(5)); + } + other => panic!("expected scroll action, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_routes_non_wheel_mouse_reports_semantically() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), + AttachInputAction::Semantic(AttachSemanticAction::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: crate::protocol::ClientMousePosition::Cell { column: 10, row: 5 }, + modifiers: 0, + }) + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_flushes_pending_prefix_before_cell_mouse() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert!(matches!( + escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), + AttachInputAction::ForwardThenSemantic( + prefix, + AttachSemanticAction::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: crate::protocol::ClientMousePosition::Cell { + column: 10, + row: 5 + }, + modifiers: 0, + } + ) if prefix == vec![0x02] + )); + } + + #[cfg(unix)] + #[test] + fn direct_attach_pixel_mouse_keeps_pixels_and_semantic_kind() { + let geometry = crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(); + let (kind, position, modifiers) = + direct_attach_pixel_mouse(b"\x1b[<0;21;22M", geometry).expect("pixel mouse"); + + assert_eq!( + kind, + crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left) + ); + assert_eq!( + position, + crate::protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + } + ); + assert_eq!(modifiers, 0); + } + + #[cfg(unix)] + #[test] + fn pixel_mouse_flushes_pending_attach_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert_eq!(escape.take_pending_prefix(), Some(vec![0x02])); + assert_eq!(escape.take_pending_prefix(), None); + } + + #[cfg(unix)] + #[test] + fn attach_escape_turns_plain_page_keys_into_scroll_actions() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[5~".to_vec(), 12, 3) { + AttachInputAction::Semantic(AttachSemanticAction::Scroll { + source, + direction, + lines, + .. + }) => { + assert_eq!( + source, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec() + } + ); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 11); + } + other => panic!("expected page-up scroll action, got {other:?}"), + } + + match escape.filter_input(b"\x1b[6~".to_vec(), 12, 3) { + AttachInputAction::Semantic(AttachSemanticAction::Scroll { + source, + direction, + lines, + .. + }) => { + assert_eq!( + source, + AttachScrollSource::PageKey { + input: b"\x1b[6~".to_vec() + } + ); + assert_eq!(direction, AttachScrollDirection::Down); + assert_eq!(lines, 11); + } + other => panic!("expected page-down scroll action, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_forwards_modified_page_key() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[5;5~".to_vec(), 12, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[5;5~"), + other => panic!("expected modified page key to forward, got {other:?}"), + } + } +} diff --git a/src/client/clipboard_images.rs b/src/client/clipboard_images.rs new file mode 100644 index 00000000..c1c49cbc --- /dev/null +++ b/src/client/clipboard_images.rs @@ -0,0 +1,248 @@ +use std::path::PathBuf; + +use tracing::{info, warn}; + +use crate::ipc::LocalStream; +#[cfg(windows)] +use crate::protocol::ClientInputEvent; +use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; +use crate::protocol::{ClientClipboardImageTarget, ClientMessage}; + +use super::{is_remote_client_process, write_to_server, ClientError}; + +pub(super) fn write_remote_image_to_server( + stream: &mut LocalStream, + target: ClientClipboardImageTarget, + image: crate::platform::ClipboardImage, + source: &'static str, +) -> Result<(), ClientError> { + if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { + warn!( + bytes = image.bytes.len(), + max = MAX_CLIPBOARD_IMAGE_PAYLOAD, + source, + "local image is too large to bridge" + ); + return Ok(()); + } + + info!( + bytes = image.bytes.len(), + extension = image.extension, + source, + "bridging local image to remote server" + ); + write_to_server( + stream, + &ClientMessage::ClipboardImage { + target, + extension: image.extension.to_owned(), + data: image.bytes, + }, + ) + .map_err(ClientError::ConnectionLost) +} + +pub(super) fn client_remote_image_paste_key( + config: &crate::config::Config, +) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { + if !is_remote_client_process() { + return None; + } + + match config.remote_image_paste_key() { + Ok(key) => key, + Err(diagnostic) => { + warn!(diagnostic = %diagnostic, "local remote image paste key config diagnostic"); + None + } + } +} + +#[cfg(unix)] +pub(super) fn should_bridge_clipboard_image_paste( + data: &[u8], + is_remote_client: bool, + remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, +) -> bool { + if data == b"\x1b[200~\x1b[201~" { + return is_remote_client; + } + + let Some(remote_image_paste_key) = remote_image_paste_key else { + return false; + }; + + let events = crate::raw_input::parse_raw_input_bytes_sync(data); + matches!( + events.as_slice(), + [crate::raw_input::RawInputEvent::Key(key)] + if key.kind == crossterm::event::KeyEventKind::Press + && crate::config::terminal_key_matches_combo(key, remote_image_paste_key) + ) +} + +#[cfg(windows)] +pub(super) fn should_bridge_clipboard_image_events( + events: &[ClientInputEvent], + is_remote_client: bool, + remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, +) -> bool { + if !is_remote_client { + return false; + } + if matches!(events, [ClientInputEvent::Paste { text }] if text.is_empty()) { + return true; + } + + let Some(remote_image_paste_key) = remote_image_paste_key else { + return false; + }; + matches!( + events, + [event] + if matches!( + event.to_raw_input_event(), + crate::raw_input::RawInputEvent::Key(key) + if key.kind == crossterm::event::KeyEventKind::Press + && crate::config::terminal_key_matches_combo( + &key, + remote_image_paste_key, + ) + ) + ) +} + +#[cfg(unix)] +pub(super) fn read_image_file_from_terminal_drop( + data: &[u8], + is_remote_client: bool, +) -> Option { + let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?; + read_image_file(path, extension) +} + +#[cfg(windows)] +pub(super) fn read_image_file_from_client_events( + events: &[ClientInputEvent], + is_remote_client: bool, +) -> Option { + let [ClientInputEvent::Paste { text }] = events else { + return None; + }; + let text = normalized_terminal_drop_text(text)?; + let (path, extension) = + image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?; + read_image_file(path, extension) +} + +fn read_image_file( + path: PathBuf, + extension: &'static str, +) -> Option { + let metadata = std::fs::metadata(&path).ok()?; + if !metadata.is_file() { + return None; + } + + let file = std::fs::File::open(&path).ok()?; + let bytes = + match crate::platform::read_limited_reader(file, MAX_CLIPBOARD_IMAGE_PAYLOAD).ok()? { + crate::platform::LimitedRead::Complete(bytes) => bytes, + crate::platform::LimitedRead::Empty => return None, + crate::platform::LimitedRead::Oversized => { + warn!( + max = MAX_CLIPBOARD_IMAGE_PAYLOAD, + "local image file drop is too large to bridge" + ); + return None; + } + }; + + Some(crate::platform::ClipboardImage { bytes, extension }) +} + +#[cfg(unix)] +pub(super) fn image_path_from_terminal_drop( + data: &[u8], + is_remote_client: bool, +) -> Option<(PathBuf, &'static str)> { + let bytes = bracketed_paste_payload(data).unwrap_or(data); + let text = std::str::from_utf8(bytes).ok()?; + let text = normalized_terminal_drop_text(text)?; + let text = unescape_terminal_drop_path(strip_matching_path_quotes(text)); + image_path_from_drop_text(&text, is_remote_client) +} + +fn normalized_terminal_drop_text(text: &str) -> Option<&str> { + let text = text.trim_end_matches(['\r', '\n']); + (!text.is_empty() && !text.contains(['\r', '\n'])).then_some(text) +} + +fn image_path_from_drop_text( + text: &str, + is_remote_client: bool, +) -> Option<(PathBuf, &'static str)> { + if !is_remote_client { + return None; + } + let path = PathBuf::from(text); + if !path.is_absolute() { + return None; + } + let extension = recognized_image_extension(path.extension()?.to_str()?)?; + Some((path, extension)) +} + +#[cfg(unix)] +fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> { + const START: &[u8] = b"\x1b[200~"; + const END: &[u8] = b"\x1b[201~"; + data.strip_prefix(START)?.strip_suffix(END) +} + +fn strip_matching_path_quotes(text: &str) -> &str { + if text.len() < 2 { + return text; + } + + let bytes = text.as_bytes(); + match (bytes.first(), bytes.last()) { + (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"')) => &text[1..text.len() - 1], + _ => text, + } +} + +#[cfg(unix)] +fn unescape_terminal_drop_path(text: &str) -> String { + let mut unescaped = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + if let Some(escaped) = chars.next() { + unescaped.push(escaped); + } else { + unescaped.push(ch); + } + } else { + unescaped.push(ch); + } + } + unescaped +} + +fn recognized_image_extension(extension: &str) -> Option<&'static str> { + if extension.eq_ignore_ascii_case("png") { + Some("png") + } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") { + Some("jpg") + } else if extension.eq_ignore_ascii_case("gif") { + Some("gif") + } else if extension.eq_ignore_ascii_case("webp") { + Some("webp") + } else if extension.eq_ignore_ascii_case("bmp") { + Some("bmp") + } else { + None + } +} diff --git a/src/client/errors.rs b/src/client/errors.rs new file mode 100644 index 00000000..460ba21f --- /dev/null +++ b/src/client/errors.rs @@ -0,0 +1,99 @@ +use std::io; + +use crate::protocol; +use crate::server::socket_paths::client_socket_path; + +/// Errors that can occur during client operation. +#[derive(Debug)] +pub enum ClientError { + /// Could not connect to the server's client socket. + ConnectionFailed(io::Error), + /// Server rejected our handshake. + HandshakeRejected { version: u32, error: String }, + /// Server shut down. + ServerShutdown { reason: Option }, + /// Lost connection to the server. + ConnectionLost(io::Error), + /// Protocol error (framing, deserialization). + Protocol(protocol::FramingError), +} + +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientError::ConnectionFailed(err) => { + write!(f, "failed to connect to server: {err}")?; + let path = client_socket_path(); + write!( + f, + "\nIs herdr server running? Start it with `herdr server`." + )?; + write!(f, "\nSocket path: {}", path.display()) + } + ClientError::HandshakeRejected { version, error } => { + write!(f, "server rejected handshake (version {version}): {error}") + } + ClientError::ServerShutdown { reason } => { + match reason.as_deref() { + Some("detached") => { + if let Ok(reattach_command) = + std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) + { + write!(f, "detached from remote server")?; + write!(f, "\nRun `{reattach_command}` to reattach")?; + } else { + write!(f, "detached from server")?; + write!( + f, + "\nRun `{}` to reattach", + crate::session::local_attach_command() + )?; + } + } + _ => { + write!(f, "server shut down")?; + if let Some(reason) = reason { + write!(f, ": {reason}")?; + } + } + } + Ok(()) + } + ClientError::ConnectionLost(err) => { + if let Ok(reattach_command) = std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) + { + write!(f, "lost connection to remote Herdr: {err}")?; + write!(f, "\nIf the remote server survived the SSH or network drop, its panes may still be running.")?; + write!(f, "\nRun `{reattach_command}` to reattach") + } else { + write!(f, "lost connection to server: {err}") + } + } + ClientError::Protocol(err) => write!(f, "protocol error: {err}"), + } + } +} + +impl std::error::Error for ClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ClientError::ConnectionFailed(err) => Some(err), + ClientError::ConnectionLost(err) => Some(err), + ClientError::Protocol(err) => Some(err), + _ => None, + } + } +} + +impl From for ClientError { + fn from(err: protocol::FramingError) -> Self { + match err { + protocol::FramingError::UnexpectedEof => ClientError::ConnectionLost(io::Error::new( + io::ErrorKind::UnexpectedEof, + "server closed connection", + )), + protocol::FramingError::Io(err) => ClientError::ConnectionLost(err), + err => ClientError::Protocol(err), + } + } +} diff --git a/src/client/frame_output.rs b/src/client/frame_output.rs new file mode 100644 index 00000000..5d12c78c --- /dev/null +++ b/src/client/frame_output.rs @@ -0,0 +1,97 @@ +use std::collections::HashSet; +use std::io; +use std::sync::{Mutex, OnceLock}; + +use crate::protocol::render_ansi; + +static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock>> = OnceLock::new(); + +pub(super) fn write_encoded_frame_with_graphics( + mut writer: impl io::Write, + encoded: &[u8], + graphics: &[u8], +) -> io::Result<()> { + if graphics.is_empty() { + return writer.write_all(encoded); + } + + let insertion = render_ansi::final_sync_output_end(encoded).unwrap_or(encoded.len()); + + writer.write_all(&encoded[..insertion])?; + record_received_kitty_graphics(graphics); + writer.write_all(b"\x1b7")?; + writer.write_all(graphics)?; + writer.write_all(b"\x1b8")?; + writer.write_all(&encoded[insertion..]) +} + +pub(super) fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool { + bytes.windows(3).any(|window| window == b"\x1b_G") +} + +pub(super) fn record_received_kitty_graphics(bytes: &[u8]) { + let ids = kitty_graphics_image_ids(bytes); + if ids.is_empty() { + return; + } + let set = RECEIVED_KITTY_GRAPHICS_IDS.get_or_init(|| Mutex::new(HashSet::new())); + if let Ok(mut set) = set.lock() { + set.extend(ids); + } +} + +pub(super) fn clear_received_kitty_graphics(mut writer: impl io::Write) -> io::Result<()> { + let Some(set) = RECEIVED_KITTY_GRAPHICS_IDS.get() else { + return Ok(()); + }; + let Ok(mut set) = set.lock() else { + return Ok(()); + }; + for id in set.drain() { + write!(writer, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\")?; + } + writer.flush() +} + +pub(super) fn kitty_graphics_image_ids(bytes: &[u8]) -> Vec { + let mut ids = Vec::new(); + let mut index = 0usize; + while let Some(start) = find_subslice(&bytes[index..], b"\x1b_G") { + let command_start = index + start + 3; + let Some(end) = find_subslice(&bytes[command_start..], b"\x1b\\") else { + break; + }; + let command = &bytes[command_start..command_start + end]; + if let Some(id) = kitty_graphics_command_image_id(command) { + ids.push(id); + } + index = command_start + end + 2; + } + ids +} + +fn kitty_graphics_command_image_id(command: &[u8]) -> Option { + let header_end = command + .iter() + .position(|byte| *byte == b';') + .unwrap_or(command.len()); + for part in command[..header_end].split(|byte| *byte == b',') { + let Some(value) = part.strip_prefix(b"i=") else { + continue; + }; + let text = std::str::from_utf8(value).ok()?; + if let Ok(id) = text.parse::() { + return Some(id); + } + } + None +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || needle.len() > haystack.len() { + return None; + } + haystack + .windows(needle.len()) + .position(|window| window == needle) +} diff --git a/src/client/handshake.rs b/src/client/handshake.rs new file mode 100644 index 00000000..aa9039bf --- /dev/null +++ b/src/client/handshake.rs @@ -0,0 +1,187 @@ +use std::io; +#[cfg(unix)] +use std::io::IsTerminal as _; +use std::time::Duration; + +use interprocess::local_socket::traits::Stream as _; +#[cfg(windows)] +use tracing::debug; +use tracing::info; + +use crate::ipc::LocalStream; +use crate::protocol::{ + self, ClientMessage, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION, +}; + +use super::{shell, terminal_setup::is_ssh_session, ClientError}; + +/// Time to wait for the server's Welcome reply during the handshake. +/// +/// A local client talks to an already-connected server, so 5s is plenty. The +/// remote bridge client (`herdr --remote`) sits behind a fresh per-attach ssh +/// connection whose cold-connect (TCP + key exchange + auth) happens inside this +/// window; on a high-latency link that easily exceeds 5s, so it gets a far +/// larger budget. See issue #753. +pub(super) const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5); +pub(super) const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60); + +pub(super) fn is_remote_client_process() -> bool { + std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok() +} + +pub(super) fn client_shell_keybinding_source() -> shell::ClientShellKeybindingSource { + match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR) + .ok() + .as_deref() + { + Some("server") => shell::ClientShellKeybindingSource::Endpoint, + Some(_) => shell::ClientShellKeybindingSource::RemoteLocal, + None => shell::ClientShellKeybindingSource::Local, + } +} + +pub(super) fn handshake_read_timeout() -> Duration { + if is_remote_client_process() { + return REMOTE_HANDSHAKE_READ_TIMEOUT; + } + LOCAL_HANDSHAKE_READ_TIMEOUT +} + +#[cfg(any(unix, test))] +pub(super) fn direct_graphics_profile_values( + term_program: &str, + term: &str, + kitty_window: bool, + blocked_transport: bool, + terminals: bool, +) -> bool { + let supported = term_program.eq_ignore_ascii_case("ghostty") + || term_program.eq_ignore_ascii_case("wezterm") + || matches!(term, "xterm-ghostty" | "xterm-kitty" | "xterm-wezterm") + || kitty_window; + supported && !blocked_transport && terminals +} + +#[cfg(unix)] +fn direct_graphics_profile_allowed() -> bool { + let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); + let term = std::env::var("TERM").unwrap_or_default(); + direct_graphics_profile_values( + &term_program, + &term, + std::env::var_os("KITTY_WINDOW_ID").is_some(), + is_remote_client_process() + || is_ssh_session() + || std::env::var_os("TMUX").is_some() + || std::env::var_os("STY").is_some(), + io::stdin().is_terminal() && io::stdout().is_terminal(), + ) +} + +#[cfg(not(unix))] +fn direct_graphics_profile_allowed() -> bool { + false +} + +#[cfg(windows)] +fn set_handshake_recv_timeout( + stream: &LocalStream, + timeout: Option, + context: &'static str, +) -> Result<(), ClientError> { + match stream.set_recv_timeout(timeout) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::Unsupported => { + debug!(err = %err, context, "client socket receive timeout unavailable"); + Ok(()) + } + Err(err) => Err(ClientError::ConnectionFailed(err)), + } +} + +#[cfg(not(windows))] +fn set_handshake_recv_timeout( + stream: &LocalStream, + timeout: Option, + _context: &'static str, +) -> Result<(), ClientError> { + stream + .set_recv_timeout(timeout) + .map_err(ClientError::ConnectionFailed) +} + +/// Performs the client→server handshake. +/// +/// Sends TerminalHello (or ClientShellHello) with the terminal size and protocol +/// version, then reads the Welcome response. +pub(super) fn do_handshake( + stream: &mut LocalStream, + cols: u16, + rows: u16, + cell_width_px: u32, + cell_height_px: u32, + exact_cell_size: bool, + shell_surface_size: Option, + endpoint_keybindings: bool, + mouse_capture: bool, +) -> Result { + stream + .set_nonblocking(false) + .map_err(ClientError::ConnectionFailed)?; + + let hello = if let Some(surface_size) = shell_surface_size { + ClientMessage::ClientShellHello { + version: PROTOCOL_VERSION, + cell_width_px, + cell_height_px, + surface_size, + pixel_mouse: exact_cell_size && cfg!(unix), + direct_graphics: exact_cell_size + && cell_width_px > 0 + && cell_height_px > 0 + && direct_graphics_profile_allowed(), + endpoint_keybindings, + mouse_capture, + } + } else { + ClientMessage::TerminalHello { + version: PROTOCOL_VERSION, + cols, + rows, + cell_width_px, + cell_height_px, + pixel_mouse: exact_cell_size && cfg!(unix), + } + }; + protocol::write_message(stream, &hello) + .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; + + set_handshake_recv_timeout( + stream, + Some(handshake_read_timeout()), + "client handshake read timeout unavailable", + )?; + let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?; + set_handshake_recv_timeout( + stream, + None, + "failed to clear client handshake read timeout", + )?; + + match welcome { + ServerMessage::Welcome { + version, + encoding, + error, + } => { + if let Some(error) = error { + return Err(ClientError::HandshakeRejected { version, error }); + } + info!(version, ?encoding, "handshake succeeded"); + Ok(encoding) + } + _ => Err(ClientError::Protocol(protocol::FramingError::Io( + io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"), + ))), + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs index b90ab4a4..6f60fdf8 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,7 +1,7 @@ //! Thin client mode — connects to the server's client socket. //! //! The client: -//! - Connects to `herdr-client.sock`, sends Hello with terminal size and protocol version +//! - Connects to `herdr-client.sock`, sends TerminalHello with terminal size and protocol version //! - Sets up the real terminal (raw mode, mouse capture, keyboard enhancements) //! - Receives Frame messages and blits them to the terminal (diff against last frame) //! - Reads stdin events (keystrokes, mouse, paste) and sends them as ClientMessage::Input @@ -12,49 +12,102 @@ //! - Forwards OSC 52 clipboard writes from server to its own stdout //! - Displays sound/toast notifications forwarded from server +mod attach; +mod clipboard_images; #[cfg(unix)] mod direct_graphics; mod endpoint_commands; +mod errors; +mod frame_output; +mod handshake; mod input; +mod notifications; mod shell; +mod terminal_geometry; +mod terminal_sessions; +mod terminal_setup; + +pub use terminal_sessions::{run_terminal_session_control, run_terminal_session_observe}; + +#[cfg(not(windows))] +use terminal_geometry::query_host_terminal_appearance; +#[cfg(test)] +use terminal_geometry::{ + cell_size_fallback, ioctl_cell_size, pack_cell_size, resize_report_required, + should_query_host_cell_size, write_host_cell_size_query, write_host_terminal_appearance_query, + write_host_terminal_theme_query, +}; +use terminal_geometry::{ + host_cell_size_query_required, initial_terminal_geometry, query_host_cell_size, + query_host_terminal_theme, resize_poll_loop, should_query_host_terminal_theme, +}; +#[cfg(unix)] +use terminal_geometry::{reported_cell_size_from_events, store_reported_cell_size}; +use terminal_setup::{ + effective_mouse_capture, effective_sgr_pixel_mouse, set_mouse_capture, + setup_direct_attach_terminal, setup_terminal, should_draw_host_cursor, +}; +#[cfg(windows)] +use terminal_setup::{ + enable_windows_virtual_terminal_input, is_ssh_session, windows_vti_input_backend_enabled, +}; +#[cfg(test)] +use terminal_setup::{ + should_enable_host_color_scheme_reports, windows_virtual_terminal_input_mode, + write_host_color_scheme_report_mode, write_terminal_restore_postlude, +}; + +#[cfg(unix)] +use attach::direct_attach_pixel_mouse; +use attach::AttachEscapeState; +#[cfg(unix)] +use attach::{write_attach_semantic_action, AttachInputAction}; +use clipboard_images::{client_remote_image_paste_key, write_remote_image_to_server}; +#[cfg(windows)] +use clipboard_images::{read_image_file_from_client_events, should_bridge_clipboard_image_events}; +#[cfg(unix)] +use clipboard_images::{read_image_file_from_terminal_drop, should_bridge_clipboard_image_paste}; +pub use errors::ClientError; +#[cfg(test)] +use frame_output::{clear_received_kitty_graphics, kitty_graphics_image_ids}; +use frame_output::{ + contains_kitty_graphics_bytes, record_received_kitty_graphics, + write_encoded_frame_with_graphics, +}; +use handshake::{client_shell_keybinding_source, do_handshake, is_remote_client_process}; +#[cfg(test)] +use handshake::{ + direct_graphics_profile_values, handshake_read_timeout, REMOTE_HANDSHAKE_READ_TIMEOUT, +}; +use notifications::{handle_notify, handle_shell_notification_effects}; +#[cfg(test)] +use notifications::{handle_notify_with_notifiers, sound_from_notify_message}; +#[cfg(test)] +use terminal_sessions::terminal_control_command_from_json; #[cfg(unix)] use std::collections::HashMap; -use std::collections::HashSet; -#[cfg(unix)] -use std::io::IsTerminal as _; -use std::io::{self, BufRead, Write as _}; +use std::io::{self, Write as _}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::Arc; +#[cfg(unix)] +use std::sync::Mutex; use std::time::Duration; -use base64::Engine; -use crossterm::event::{ - DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, - EnableFocusChange, EnableMouseCapture, -}; -#[cfg(unix)] -use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; -#[cfg(not(windows))] -use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}; -use crossterm::execute; -use crossterm::terminal::{DisableLineWrap, EnableLineWrap}; use interprocess::local_socket::traits::Stream as _; use interprocess::TryClone as _; use tracing::{debug, info, warn}; use crate::ipc::LocalStream; use crate::protocol::render_ansi; -use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; use crate::protocol::{ - self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode, - ClientMessage, FrameData, NotifyKind, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, - MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, ClientMessage, FrameData, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, }; +#[cfg(test)] +use crate::protocol::{AttachScrollDirection, AttachScrollSource, NotifyKind}; use crate::server::socket_paths::client_socket_path; -static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock>> = OnceLock::new(); - // --------------------------------------------------------------------------- // Client state // --------------------------------------------------------------------------- @@ -65,6 +118,8 @@ struct ClientLoopConfig { redraw_on_focus_gained: bool, host_cursor: crate::config::HostCursorModeConfig, kitty_graphics_enabled: bool, + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, mouse_capture_active: bool, remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, shell_config: Option, @@ -76,9 +131,21 @@ struct ClientState { blit_encoder: render_ansi::BlitEncoder, /// Whether host mouse capture is currently active. mouse_capture_active: bool, - /// Whether the host terminal currently reports all keys as Kitty sequences. + /// Last mouse-capture demand published by the endpoint. + endpoint_mouse_capture_requested: bool, + /// Last exact-pixel mouse demand published by the endpoint. + endpoint_sgr_pixels_requested: bool, + /// Client-local direct-attach preference, combined with child mouse demand. + direct_mouse_capture_preference: bool, + /// Client-owned shell mouse-capture preference. + shell_mouse_capture_preference: bool, + /// Host keyboard protocol state currently owned for a direct terminal attach. + direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState, + /// Focused endpoint pane/popup demand for Kitty report-all input. + pane_keyboard_report_all: bool, + /// Whether the client-owned shell currently enabled host report-all input. keyboard_report_all_active: bool, - /// The terminal size we reported to the server in our last Hello/Resize. + /// The terminal size we reported to the server in our last handshake/Resize. reported_size: (u16, u16), /// Last exact host cell size used by client-rendered surfaces. reported_cell_size: (u32, u32), @@ -86,6 +153,10 @@ struct ClientState { sound_config: crate::config::SoundConfig, /// Whether this client may write Kitty graphics bytes to its host terminal. kitty_graphics_enabled: bool, + /// Whether resize reports inspect host pixel geometry. + pixel_geometry_enabled: bool, + /// Whether the latest host pixel geometry is exact enough for pixel mouse input. + pixel_geometry_exact: bool, /// One bounded matcher, inactive unless a direct transmission is armed. #[cfg(unix)] direct_graphics_response: Arc>, @@ -95,7 +166,7 @@ struct ClientState { /// ClientShell assets waiting for the host terminal's direct-upload response. #[cfg(unix)] pending_surface_graphics: HashMap, - /// Direct attach prefix escape state. None for full-app clients. + /// Direct attach prefix escape state. None for ClientShell connections. attach_escape: Option, /// Rows scrolled for one direct-attach wheel notch. #[cfg(unix)] @@ -108,144 +179,22 @@ struct ClientState { repaint_pending: bool, /// Whether this client draws the cursor into frame cells instead of using the host cursor. draw_host_cursor: bool, + /// Browser opener processes launched by client-owned link activation. + detached_process_children: Vec, /// Experimental client-owned shell state. shell: Option, } -#[derive(Debug, Default)] -#[cfg(windows)] -struct AttachEscapeState; - -#[derive(Debug, Default)] -#[cfg(unix)] -struct AttachEscapeState { - pending_prefix: bool, -} - -#[derive(Debug)] -#[cfg(unix)] -enum AttachInputAction { - Forward(Vec), - ForwardAfterPendingPrefix(Vec), - Scroll { - source: AttachScrollSource, - direction: AttachScrollDirection, - lines: u16, - column: Option, - row: Option, - modifiers: u8, - }, - Detach, - None, -} - -impl AttachEscapeState { - #[cfg(unix)] - fn filter_input( - &mut self, - data: Vec, - viewport_rows: u16, - mouse_scroll_lines: usize, - ) -> AttachInputAction { - const PREFIX: u8 = 0x02; // Ctrl+B - - if crate::raw_input::is_complete_text_bracketed_paste(&data) { - return if std::mem::take(&mut self.pending_prefix) { - AttachInputAction::ForwardAfterPendingPrefix(data) - } else { - AttachInputAction::Forward(data) - }; +impl Drop for ClientState { + fn drop(&mut self) { + if self.attach_escape.is_some() { + let _ = crate::terminal_modes::set_direct_host_keyboard_protocol( + &mut io::stdout(), + &mut self.direct_keyboard_protocol, + 0, + 0, + ); } - - let mut output = Vec::with_capacity(data.len()); - for byte in data { - if self.pending_prefix { - self.pending_prefix = false; - match byte { - b'q' => return AttachInputAction::Detach, - PREFIX => output.push(PREFIX), - other => { - output.push(PREFIX); - output.push(other); - } - } - continue; - } - - if byte == PREFIX { - self.pending_prefix = true; - } else { - output.push(byte); - } - } - - if output.is_empty() { - AttachInputAction::None - } else if let Some(action) = - attach_scroll_action(&output, viewport_rows, mouse_scroll_lines) - { - action - } else { - AttachInputAction::Forward(output) - } - } -} - -#[cfg(unix)] -fn attach_scroll_action( - data: &[u8], - viewport_rows: u16, - mouse_scroll_lines: usize, -) -> Option { - let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); - if events.len() != 1 { - return None; - } - - match events.pop()? { - crate::raw_input::RawInputEvent::Mouse(mouse) => { - let direction = match mouse.kind { - MouseEventKind::ScrollUp => AttachScrollDirection::Up, - MouseEventKind::ScrollDown => AttachScrollDirection::Down, - _ => return Some(AttachInputAction::None), - }; - Some(AttachInputAction::Scroll { - source: AttachScrollSource::Wheel, - direction, - lines: mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, - column: Some(mouse.column), - row: Some(mouse.row), - modifiers: mouse.modifiers.bits(), - }) - } - crate::raw_input::RawInputEvent::Key(key) - if key.modifiers.is_empty() - && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => - { - let direction = match key.code { - KeyCode::PageUp => AttachScrollDirection::Up, - KeyCode::PageDown => AttachScrollDirection::Down, - _ => return None, - }; - Some(AttachInputAction::Scroll { - source: AttachScrollSource::PageKey { - input: data.to_vec(), - }, - direction, - lines: viewport_rows.saturating_sub(1).max(1), - column: None, - row: None, - modifiers: KeyModifiers::empty().bits(), - }) - } - crate::raw_input::RawInputEvent::Key(key) - if key.modifiers.is_empty() - && key.kind == KeyEventKind::Release - && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) => - { - Some(AttachInputAction::None) - } - _ => None, } } @@ -288,651 +237,6 @@ impl ClientState { } } -// --------------------------------------------------------------------------- -// Error types -// --------------------------------------------------------------------------- - -/// Errors that can occur during client operation. -#[derive(Debug)] -pub enum ClientError { - /// Could not connect to the server's client socket. - ConnectionFailed(io::Error), - /// Server rejected our handshake. - HandshakeRejected { version: u32, error: String }, - /// Server shut down. - ServerShutdown { reason: Option }, - /// Lost connection to the server. - ConnectionLost(io::Error), - /// Protocol error (framing, deserialization). - Protocol(protocol::FramingError), -} - -impl std::fmt::Display for ClientError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ClientError::ConnectionFailed(err) => { - write!(f, "failed to connect to server: {err}")?; - let path = client_socket_path(); - write!( - f, - "\nIs herdr server running? Start it with `herdr server`." - )?; - write!(f, "\nSocket path: {}", path.display()) - } - ClientError::HandshakeRejected { version, error } => { - write!(f, "server rejected handshake (version {version}): {error}") - } - ClientError::ServerShutdown { reason } => { - match reason.as_deref() { - Some("detached") => { - if let Ok(reattach_command) = - std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) - { - write!(f, "detached from remote server")?; - write!(f, "\nRun `{reattach_command}` to reattach")?; - } else { - write!(f, "detached from server")?; - write!( - f, - "\nRun `{}` to reattach", - crate::session::local_attach_command() - )?; - } - } - _ => { - write!(f, "server shut down")?; - if let Some(reason) = reason { - write!(f, ": {reason}")?; - } - } - } - Ok(()) - } - ClientError::ConnectionLost(err) => { - if let Ok(reattach_command) = std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) - { - write!(f, "lost connection to remote Herdr: {err}")?; - write!(f, "\nIf the remote server survived the SSH or network drop, its panes may still be running.")?; - write!(f, "\nRun `{reattach_command}` to reattach") - } else { - write!(f, "lost connection to server: {err}") - } - } - ClientError::Protocol(err) => { - write!(f, "protocol error: {err}") - } - } - } -} - -impl std::error::Error for ClientError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - ClientError::ConnectionFailed(err) => Some(err), - ClientError::ConnectionLost(err) => Some(err), - ClientError::Protocol(err) => Some(err), - _ => None, - } - } -} - -impl From for ClientError { - fn from(err: protocol::FramingError) -> Self { - match err { - protocol::FramingError::UnexpectedEof => ClientError::ConnectionLost(io::Error::new( - io::ErrorKind::UnexpectedEof, - "server closed connection", - )), - protocol::FramingError::Io(err) => ClientError::ConnectionLost(err), - err => ClientError::Protocol(err), - } - } -} - -// --------------------------------------------------------------------------- -// Terminal setup / restore -// --------------------------------------------------------------------------- - -/// Sets up the terminal for client mode (raw mode, optional mouse, keyboard enhancements). -/// -/// Returns a guard that restores the terminal when dropped. -fn setup_terminal(mouse_capture: bool) -> io::Result { - setup_terminal_with_capabilities(true, mouse_capture) -} - -/// Sets up a direct attach terminal. -/// -/// Direct attach forwards stdin to the attached PTY. When configured, mouse -/// capture lets wheel events drive the attached viewport or reach child -/// programs that requested mouse input. -fn setup_direct_attach_terminal(mouse_capture: bool) -> io::Result { - setup_terminal_with_capabilities(false, mouse_capture) -} - -fn setup_terminal_with_capabilities( - enable_client_protocols: bool, - mouse_capture: bool, -) -> io::Result { - ratatui::init(); - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; - let host_color_scheme_reports = - should_enable_host_color_scheme_reports(enable_client_protocols); - - #[cfg(windows)] - let windows_ssh_session = is_ssh_session(); - #[cfg(windows)] - let mut windows_virtual_terminal_input = - if windows_vti_input_backend_enabled() && windows_ssh_session { - enable_windows_virtual_terminal_input() - } else { - WindowsVirtualTerminalInputSetup::default() - }; - - if enable_client_protocols { - set_mouse_capture(mouse_capture, false)?; - execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; - if host_color_scheme_reports { - write_host_color_scheme_report_mode(&mut io::stdout(), true)?; - } - push_keyboard_enhancement_flags()?; - } else { - if should_query_host_terminal_theme() { - write_host_color_scheme_report_mode(&mut io::stdout(), false)?; - } - set_mouse_capture(mouse_capture, false)?; - execute!(io::stdout(), EnableBracketedPaste)?; - } - - #[cfg(windows)] - if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { - windows_virtual_terminal_input = enable_windows_virtual_terminal_input(); - } - - #[cfg(windows)] - if enable_client_protocols - && windows_vti_input_backend_enabled() - && windows_virtual_terminal_input.active - && windows_win32_input_mode_enabled() - { - if let Err(err) = enable_windows_win32_input_mode(&mut io::stdout()) { - if let Some(mode) = windows_virtual_terminal_input.restore_mode { - restore_windows_input_mode_value(mode); - } - return Err(err); - } - } - - let modify_other_keys_mode = enable_client_protocols - .then(crate::input::host_modify_other_keys_mode) - .flatten(); - if let Some(mode) = modify_other_keys_mode { - io::stdout().write_all(mode.set_sequence())?; - io::stdout().flush()?; - } - - execute!(io::stdout(), DisableLineWrap)?; - - Ok(TerminalGuard { - reset_modify_other_keys: modify_other_keys_mode.is_some(), - reset_host_color_scheme_reports: host_color_scheme_reports, - restored: false, - #[cfg(windows)] - restore_windows_input_mode: windows_virtual_terminal_input.restore_mode, - }) -} - -fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool { - enable_client_protocols && should_query_host_terminal_theme() -} - -/// Guard that restores the terminal when dropped. -struct TerminalGuard { - reset_modify_other_keys: bool, - reset_host_color_scheme_reports: bool, - restored: bool, - #[cfg(windows)] - restore_windows_input_mode: Option, -} - -fn write_host_color_scheme_report_mode( - writer: &mut impl io::Write, - enabled: bool, -) -> io::Result<()> { - let sequence = if enabled { - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE - } else { - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE - }; - writer.write_all(sequence.as_bytes())?; - writer.flush() -} - -fn write_terminal_restore_postlude( - writer: &mut impl io::Write, - reset_host_color_scheme_reports: bool, -) -> io::Result<()> { - if reset_host_color_scheme_reports { - writer.write_all( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), - )?; - } - // Restore a visible cursor and reset DECSCUSR back to the terminal default. - writer.write_all(b"\x1b[?25h\x1b[0 q")?; - writer.flush() -} - -fn should_draw_host_cursor(mode: crate::config::HostCursorModeConfig) -> bool { - match mode { - crate::config::HostCursorModeConfig::Auto => { - crate::platform::should_draw_host_cursor_by_default() - } - crate::config::HostCursorModeConfig::Native => false, - crate::config::HostCursorModeConfig::Drawn => true, - } -} - -#[cfg(windows)] -#[derive(Default)] -struct WindowsVirtualTerminalInputSetup { - active: bool, - restore_mode: Option, -} - -#[cfg(windows)] -fn enable_windows_virtual_terminal_input() -> WindowsVirtualTerminalInputSetup { - use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::System::Console::{ - GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT, - STD_INPUT_HANDLE, - }; - - let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - tracing::warn!("failed to get Windows console input handle for VT input"); - return WindowsVirtualTerminalInputSetup::default(); - } - - let mut mode = 0; - if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { - tracing::warn!("failed to read Windows console input mode for VT input"); - return WindowsVirtualTerminalInputSetup::default(); - } - - let desired = windows_virtual_terminal_input_mode(mode); - if desired == mode { - return WindowsVirtualTerminalInputSetup { - active: true, - restore_mode: None, - }; - } - - if unsafe { SetConsoleMode(handle, desired) } == 0 { - tracing::warn!("failed to enable Windows virtual terminal input"); - return WindowsVirtualTerminalInputSetup::default(); - } - - let mut applied = 0; - if unsafe { GetConsoleMode(handle, &mut applied) } == 0 { - tracing::warn!("failed to verify Windows virtual terminal input mode"); - let _ = unsafe { SetConsoleMode(handle, mode) }; - return WindowsVirtualTerminalInputSetup::default(); - } - if applied & ENABLE_VIRTUAL_TERMINAL_INPUT == 0 { - tracing::warn!("Windows virtual terminal input bit did not stick"); - let _ = unsafe { SetConsoleMode(handle, mode) }; - return WindowsVirtualTerminalInputSetup::default(); - } - - WindowsVirtualTerminalInputSetup { - active: true, - restore_mode: Some(mode), - } -} - -#[cfg(windows)] -fn windows_vti_input_backend_enabled() -> bool { - std::env::var("HERDR_WINDOWS_INPUT_BACKEND") - .map(|backend| !backend.eq_ignore_ascii_case("crossterm")) - .unwrap_or(true) -} - -#[cfg(any(windows, test))] -fn windows_virtual_terminal_input_mode(mode: u32) -> u32 { - mode | 0x0200 -} - -#[cfg(windows)] -fn restore_windows_input_mode_value(mode: u32) { - use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::System::Console::{GetStdHandle, SetConsoleMode, STD_INPUT_HANDLE}; - - let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - return; - } - if unsafe { SetConsoleMode(handle, mode) } == 0 { - tracing::warn!("failed to restore Windows console input mode"); - } -} - -fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<()> { - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; - #[cfg(windows)] - if is_ssh_session() && windows_vti_input_backend_enabled() { - return crate::terminal_modes::set_windows_ssh_mouse_reporting( - &mut io::stdout(), - enabled, - sgr_pixels, - ); - } - if enabled { - execute!(io::stdout(), EnableMouseCapture)?; - if sgr_pixels { - io::stdout().write_all(b"\x1b[?1016h")?; - io::stdout().flush()?; - } - Ok(()) - } else { - match execute!(io::stdout(), DisableMouseCapture) { - Ok(()) => Ok(()), - #[cfg(windows)] - Err(err) if err.to_string() == "Initial console modes not set" => Ok(()), - Err(err) => Err(err), - } - } -} - -fn restore_terminal_state( - reset_modify_other_keys: bool, - reset_host_color_scheme_reports: bool, - #[cfg(windows)] restore_windows_input_mode: Option, -) -> io::Result<()> { - let _ = clear_received_kitty_graphics(&mut io::stdout()); - - // Reset modifyOtherKeys if we enabled it. - if reset_modify_other_keys { - let _ = io::stdout().write_all(b"\x1b[>4;0m"); - let _ = io::stdout().flush(); - } - - let _ = pop_keyboard_enhancement_flags(); - - let _ = execute!( - io::stdout(), - EnableLineWrap, - DisableFocusChange, - DisableBracketedPaste - ); - let _ = set_mouse_capture(false, false); - #[cfg(windows)] - if let Some(mode) = restore_windows_input_mode { - restore_windows_input_mode_value(mode); - } - - let restore_result = ratatui::try_restore(); - let postlude_result = - write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports); - - #[cfg(windows)] - if windows_vti_input_backend_enabled() && windows_win32_input_mode_enabled() { - let _ = disable_windows_win32_input_mode(&mut io::stdout()); - } - - restore_result.and(postlude_result) -} - -#[cfg(not(windows))] -fn push_keyboard_enhancement_flags() -> io::Result<()> { - execute!( - io::stdout(), - PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) - ) -} - -#[cfg(windows)] -fn push_keyboard_enhancement_flags() -> io::Result<()> { - Ok(()) -} - -#[cfg(not(windows))] -fn pop_keyboard_enhancement_flags() -> io::Result<()> { - execute!(io::stdout(), PopKeyboardEnhancementFlags) -} - -#[cfg(windows)] -fn pop_keyboard_enhancement_flags() -> io::Result<()> { - Ok(()) -} - -#[cfg(windows)] -fn windows_win32_input_mode_enabled() -> bool { - std::env::var("HERDR_WINDOWS_INPUT_PROBE") - .map(|probe| probe.eq_ignore_ascii_case("win32")) - .unwrap_or(true) -} - -#[cfg(windows)] -fn enable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { - writer.write_all(b"\x1b[?9001h")?; - writer.flush() -} - -#[cfg(windows)] -fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { - writer.write_all(b"\x1b[?9001l")?; - writer.flush() -} - -impl TerminalGuard { - fn restore(mut self) -> io::Result<()> { - self.restored = true; - restore_terminal_state( - self.reset_modify_other_keys, - self.reset_host_color_scheme_reports, - #[cfg(windows)] - self.restore_windows_input_mode, - ) - } -} - -impl Drop for TerminalGuard { - fn drop(&mut self) { - if !self.restored { - let _ = restore_terminal_state( - self.reset_modify_other_keys, - self.reset_host_color_scheme_reports, - #[cfg(windows)] - self.restore_windows_input_mode, - ); - } - } -} - -// --------------------------------------------------------------------------- -// Handshake -// --------------------------------------------------------------------------- - -fn is_remote_client_process() -> bool { - std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok() -} - -fn is_ssh_session() -> bool { - std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() -} - -fn client_shell_keybinding_source() -> shell::ClientShellKeybindingSource { - match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR) - .ok() - .as_deref() - { - Some("server") => shell::ClientShellKeybindingSource::Endpoint, - Some(_) => shell::ClientShellKeybindingSource::RemoteLocal, - None => shell::ClientShellKeybindingSource::Local, - } -} - -/// Time to wait for the server's Welcome reply during the handshake. -/// -/// A local client talks to an already-connected server, so 5s is plenty. The -/// remote bridge client (`herdr --remote`) sits behind a fresh per-attach ssh -/// connection whose cold-connect (TCP + key exchange + auth) happens inside this -/// window; on a high-latency link that easily exceeds 5s, so it gets a far -/// larger budget. See issue #753. -const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5); -const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60); - -fn handshake_read_timeout() -> Duration { - if is_remote_client_process() { - return REMOTE_HANDSHAKE_READ_TIMEOUT; - } - LOCAL_HANDSHAKE_READ_TIMEOUT -} - -#[cfg(any(unix, test))] -fn direct_graphics_profile_values( - term_program: &str, - term: &str, - kitty_window: bool, - blocked_transport: bool, - terminals: bool, -) -> bool { - let supported = term_program.eq_ignore_ascii_case("ghostty") - || term_program.eq_ignore_ascii_case("wezterm") - || matches!(term, "xterm-ghostty" | "xterm-kitty" | "xterm-wezterm") - || kitty_window; - supported && !blocked_transport && terminals -} - -#[cfg(unix)] -fn direct_graphics_profile_allowed(direct_attach: bool) -> bool { - let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); - let term = std::env::var("TERM").unwrap_or_default(); - direct_graphics_profile_values( - &term_program, - &term, - std::env::var_os("KITTY_WINDOW_ID").is_some(), - direct_attach - || is_remote_client_process() - || is_ssh_session() - || std::env::var_os("TMUX").is_some() - || std::env::var_os("STY").is_some(), - io::stdin().is_terminal() && io::stdout().is_terminal(), - ) -} - -#[cfg(not(unix))] -fn direct_graphics_profile_allowed(_direct_attach: bool) -> bool { - false -} - -#[cfg(windows)] -fn set_handshake_recv_timeout( - stream: &LocalStream, - timeout: Option, - context: &'static str, -) -> Result<(), ClientError> { - match stream.set_recv_timeout(timeout) { - Ok(()) => Ok(()), - Err(err) if err.kind() == io::ErrorKind::Unsupported => { - debug!(err = %err, context, "client socket receive timeout unavailable"); - Ok(()) - } - Err(err) => Err(ClientError::ConnectionFailed(err)), - } -} - -#[cfg(not(windows))] -fn set_handshake_recv_timeout( - stream: &LocalStream, - timeout: Option, - _context: &'static str, -) -> Result<(), ClientError> { - stream - .set_recv_timeout(timeout) - .map_err(ClientError::ConnectionFailed) -} - -/// Performs the client→server handshake. -/// -/// Sends Hello with the terminal size and protocol version, reads the Welcome -/// response. Returns Ok(()) on success, or an error if the server rejects us. -fn do_handshake( - stream: &mut LocalStream, - cols: u16, - rows: u16, - cell_width_px: u32, - cell_height_px: u32, - exact_cell_size: bool, - shell_surface_size: Option, - endpoint_keybindings: bool, -) -> Result { - stream - .set_nonblocking(false) - .map_err(ClientError::ConnectionFailed)?; - - // Send the matching handshake without changing the released Hello shape. - let hello = if let Some(surface_size) = shell_surface_size { - ClientMessage::ClientShellHello { - version: PROTOCOL_VERSION, - cols, - rows, - cell_width_px, - cell_height_px, - requested_encoding: RenderEncoding::SemanticFrame, - surface_size, - pixel_mouse: exact_cell_size && cfg!(unix), - direct_graphics: exact_cell_size - && cell_width_px > 0 - && cell_height_px > 0 - && direct_graphics_profile_allowed(false), - endpoint_keybindings, - } - } else { - ClientMessage::Hello { - version: PROTOCOL_VERSION, - cols, - rows, - cell_width_px, - cell_height_px, - requested_encoding: RenderEncoding::TerminalAnsi, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::TerminalAttach, - } - }; - protocol::write_message(stream, &hello) - .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; - - // Read Welcome. - set_handshake_recv_timeout( - stream, - Some(handshake_read_timeout()), - "client handshake read timeout unavailable", - )?; - let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?; - set_handshake_recv_timeout( - stream, - None, - "failed to clear client handshake read timeout", - )?; - - match welcome { - ServerMessage::Welcome { - version, - encoding, - error, - } => { - if let Some(error) = error { - return Err(ClientError::HandshakeRejected { version, error }); - } - info!(version, ?encoding, "handshake succeeded"); - Ok(encoding) - } - _ => Err(ClientError::Protocol(protocol::FramingError::Io( - io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"), - ))), - } -} - // --------------------------------------------------------------------------- // Client event loop // --------------------------------------------------------------------------- @@ -950,8 +254,8 @@ enum ClientLoopEvent { /// Structured input events from platforms without Unix-style stdin bytes. #[cfg(windows)] StdinEvents(Vec), - /// Terminal resize detected. - Resize(u16, u16, u32, u32), + /// Terminal resize detected, including current exact-pixel eligibility. + Resize(u16, u16, u32, u32, bool), /// Server message received. ServerMessage(Box), /// Server reader thread exited (connection lost). @@ -988,255 +292,6 @@ pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result< )) } -/// Runs a read-only terminal session observer and prints one JSON envelope per frame. -pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io::Result<()> { - let mut stream = - connect_terminal_session_stream(target.clone(), cols, rows, "observing terminal session")?; - write_to_server(&mut stream, &ClientMessage::ObserveTerminal { target })?; - write_terminal_session_output(stream) -} - -/// Runs a writable terminal session controller. -pub fn run_terminal_session_control( - target: String, - takeover: bool, - cols: u16, - rows: u16, -) -> io::Result<()> { - let mut stream = connect_terminal_session_stream( - target.clone(), - cols, - rows, - "controlling terminal session", - )?; - write_to_server( - &mut stream, - &ClientMessage::ControlTerminal { target, takeover }, - )?; - - let mut write_stream = stream.try_clone()?; - let _input_thread = std::thread::spawn(move || { - let stdin = io::stdin(); - for line in stdin.lock().lines() { - let Ok(line) = line else { - break; - }; - if line.trim().is_empty() { - continue; - } - match terminal_control_command_from_json(&line) { - Ok(message) => { - let release = matches!(message, ClientMessage::Detach); - if write_to_server(&mut write_stream, &message).is_err() { - return; - } - if release { - return; - } - } - Err(err) => eprintln!("herdr: terminal session control input ignored: {err}"), - } - } - let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); - }); - - write_terminal_session_output(stream) -} - -fn connect_terminal_session_stream( - target: String, - cols: u16, - rows: u16, - log_message: &'static str, -) -> io::Result { - init_logging(); - - let socket_path = client_socket_path(); - crate::logging::startup("client"); - info!(path = %socket_path.display(), target = %target, cols, rows, "{log_message}"); - - let mut stream = match crate::ipc::connect_local_stream(&socket_path) { - Ok(stream) => stream, - Err(err) => { - eprintln!("herdr: {}", ClientError::ConnectionFailed(err)); - std::process::exit(1); - } - }; - - match do_handshake(&mut stream, cols, rows, 0, 0, false, None, false) { - Ok(RenderEncoding::TerminalAnsi) => {} - Ok(encoding) => { - eprintln!( - "herdr: terminal session observe negotiated unsupported encoding {encoding:?}" - ); - std::process::exit(1); - } - Err(err) => { - eprintln!("herdr: {err}"); - std::process::exit(1); - } - } - - stream.set_nonblocking(false)?; - Ok(stream) -} - -fn write_terminal_session_output(mut stream: LocalStream) -> io::Result<()> { - let mut stdout = io::stdout().lock(); - loop { - match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) { - Ok(ServerMessage::Terminal(frame)) => { - let encoded = base64::engine::general_purpose::STANDARD.encode(&frame.bytes); - let line = serde_json::json!({ - "type": "terminal.frame", - "seq": frame.seq, - "encoding": "ansi", - "width": frame.width, - "height": frame.height, - "full": frame.full, - "bytes": encoded, - }); - serde_json::to_writer(&mut stdout, &line)?; - stdout.write_all(b"\n")?; - stdout.flush()?; - } - Ok(ServerMessage::ServerShutdown { reason }) => { - let line = serde_json::json!({ - "type": "terminal.closed", - "reason": reason, - }); - serde_json::to_writer(&mut stdout, &line)?; - stdout.write_all(b"\n")?; - stdout.flush()?; - return Ok(()); - } - Ok(ServerMessage::Graphics { .. }) => {} - Ok(_) => {} - Err(protocol::FramingError::UnexpectedEof) => return Ok(()), - Err(err) => return Err(io::Error::other(err.to_string())), - } - } -} - -#[derive(serde::Deserialize)] -#[serde(tag = "type")] -enum TerminalControlCommand { - #[serde(rename = "terminal.input")] - Input { - text: Option, - bytes: Option, - }, - #[serde(rename = "terminal.resize")] - Resize { - cols: u16, - rows: u16, - #[serde(default)] - cell_width_px: u32, - #[serde(default)] - cell_height_px: u32, - }, - #[serde(rename = "terminal.scroll")] - Scroll { - direction: TerminalControlScrollDirection, - lines: u16, - #[serde(default)] - source: TerminalControlScrollSource, - #[serde(default)] - column: Option, - #[serde(default)] - row: Option, - #[serde(default)] - modifiers: u8, - }, - #[serde(rename = "terminal.release")] - Release {}, -} - -#[derive(Clone, Copy, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -enum TerminalControlScrollDirection { - Up, - Down, -} - -#[derive(Clone, Copy, Default, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -enum TerminalControlScrollSource { - #[default] - Wheel, - PageKey, -} - -fn terminal_control_command_from_json(raw: &str) -> Result { - let command = serde_json::from_str::(raw) - .map_err(|err| format!("invalid json command: {err}"))?; - match command { - TerminalControlCommand::Input { text, bytes } => { - let data = match (text, bytes) { - (Some(_), Some(_)) => { - return Err("terminal.input accepts text or bytes, not both".into()) - } - (Some(text), None) => text.into_bytes(), - (None, Some(bytes)) => base64::engine::general_purpose::STANDARD - .decode(bytes) - .map_err(|err| format!("invalid terminal.input bytes: {err}"))?, - (None, None) => Vec::new(), - }; - Ok(ClientMessage::Input { data }) - } - TerminalControlCommand::Resize { - cols, - rows, - cell_width_px, - cell_height_px, - } => { - if cols == 0 || rows == 0 { - return Err("terminal.resize cols and rows must be greater than 0".into()); - } - Ok(ClientMessage::Resize { - cols, - rows, - cell_width_px, - cell_height_px, - }) - } - TerminalControlCommand::Scroll { - direction, - lines, - source, - column, - row, - modifiers, - } => { - if lines == 0 { - return Err("terminal.scroll lines must be greater than 0".into()); - } - let direction = match direction { - TerminalControlScrollDirection::Up => AttachScrollDirection::Up, - TerminalControlScrollDirection::Down => AttachScrollDirection::Down, - }; - let source = match source { - TerminalControlScrollSource::Wheel => AttachScrollSource::Wheel, - TerminalControlScrollSource::PageKey => AttachScrollSource::PageKey { - input: match direction { - AttachScrollDirection::Up => b"\x1b[5~".to_vec(), - AttachScrollDirection::Down => b"\x1b[6~".to_vec(), - }, - }, - }; - Ok(ClientMessage::AttachScroll { - source, - direction, - lines, - column, - row, - modifiers, - }) - } - TerminalControlCommand::Release {} => Ok(ClientMessage::Detach), - } -} - fn run_client_with_mode( attach_request: Option<(String, bool)>, attach_escape: Option, @@ -1269,12 +324,15 @@ fn run_client_with_mode( let remote_image_paste_key = client_remote_image_paste_key(&loaded_config.config); let kitty_graphics_enabled = loaded_config.config.experimental.kitty_graphics && client_rendered_shell; + let pixel_geometry_enabled = kitty_graphics_enabled || attach_escape.is_some(); let loop_config = ClientLoopConfig { sound_config: loaded_config.config.ui.sound, mouse_scroll_lines, redraw_on_focus_gained, host_cursor, kitty_graphics_enabled, + pixel_geometry_enabled, + pixel_geometry_fallback: kitty_graphics_enabled, mouse_capture_active: mouse_capture, remote_image_paste_key, shell_config, @@ -1296,7 +354,7 @@ fn run_client_with_mode( // Get the terminal geometry before handshake (before raw mode). let (cols, rows, cell_width_px, cell_height_px, exact_cell_size) = - initial_terminal_geometry(kitty_graphics_enabled); + initial_terminal_geometry(pixel_geometry_enabled, kitty_graphics_enabled); let shell_surface_size = loop_config .shell_config @@ -1317,6 +375,7 @@ fn run_client_with_mode( exact_cell_size, shell_surface_size, endpoint_keybindings, + loop_config.mouse_capture_active, ) { Ok(encoding) => encoding, Err(err) => { @@ -1350,18 +409,10 @@ fn run_client_with_mode( })?; // Install a panic hook so the foreground client always restores its terminal. - let panic_resets_modify_other_keys = terminal_guard.reset_modify_other_keys; - let panic_resets_host_color_scheme_reports = terminal_guard.reset_host_color_scheme_reports; - #[cfg(windows)] - let panic_restore_windows_input_mode = terminal_guard.restore_windows_input_mode; + let panic_restore = terminal_guard.panic_restore(); let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - let _ = restore_terminal_state( - panic_resets_modify_other_keys, - panic_resets_host_color_scheme_reports, - #[cfg(windows)] - panic_restore_windows_input_mode, - ); + panic_restore(); original_hook(info); })); @@ -1389,6 +440,7 @@ fn run_client_with_mode( rows, cell_width_px, cell_height_px, + exact_cell_size, should_quit, loop_config, negotiated_encoding, @@ -1429,7 +481,9 @@ fn dispatch_client_shell_actions( actions: Vec, endpoint_commands: &mut endpoint_commands::EndpointCommands, write_stream: &mut LocalStream, -) -> Result<(), ClientError> { + detached_process_children: &mut Vec, +) -> Result, ClientError> { + let mut replay_mouse = Vec::new(); for action in actions { match action { shell::ClientShellAction::Endpoint { boot_id, request } => { @@ -1438,6 +492,19 @@ fn dispatch_client_shell_actions( shell::ClientShellAction::ClipboardWrite(bytes) => { crate::selection::write_osc52_bytes(&bytes); } + shell::ClientShellAction::Request(request) => { + write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; + } + shell::ClientShellAction::OpenSafeWebUrl(url) => { + if crate::app::actions::safe_web_url(&url).is_some() { + match crate::platform::open_url(&url) { + Ok(Some(child)) => detached_process_children.push(child), + Ok(None) => {} + Err(err) => warn!(err = %err, url = %url, "failed to open pane URL"), + } + } + } + shell::ClientShellAction::ReplayMouse(events) => replay_mouse.extend(events), shell::ClientShellAction::Keybind(action) => { debug!( ?action, @@ -1448,7 +515,8 @@ fn dispatch_client_shell_actions( } endpoint_commands .send_next(write_stream) - .map_err(ClientError::ConnectionLost) + .map_err(ClientError::ConnectionLost)?; + Ok(replay_mouse) } fn client_shell_resize_message( @@ -1457,13 +525,45 @@ fn client_shell_resize_message( rows: u16, cell_width_px: u32, cell_height_px: u32, + pixel_mouse: bool, ) -> ClientMessage { ClientMessage::ClientShellResize { - cols, - rows, cell_width_px, cell_height_px, surface_size: shell.surface_size(cols, rows), + pixel_mouse, + } +} + +fn sync_client_shell_keyboard_report_all(state: &mut ClientState) -> Result<(), ClientError> { + let Some(shell) = state.shell.as_ref() else { + return Ok(()); + }; + let desired = state.pane_keyboard_report_all || shell.host_keyboard_report_all_requested(); + if desired == state.keyboard_report_all_active { + return Ok(()); + } + crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired) + .map_err(ClientError::ConnectionFailed)?; + state.keyboard_report_all_active = desired; + Ok(()) +} + +fn apply_client_shell_input_source_changes( + state: &mut ClientState, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) { + let changes = state + .shell + .as_mut() + .map(shell::ClientShellState::take_input_source_changes) + .unwrap_or_default(); + for active in changes { + if active { + prefix_input_source.switch_to_ascii(); + } else { + prefix_input_source.restore(); + } } } @@ -1473,7 +573,9 @@ fn finish_client_shell_input( frame: Option, write_stream: &mut LocalStream, endpoint_commands: &mut endpoint_commands::EndpointCommands, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, ) -> Result { + apply_client_shell_input_source_changes(state, prefix_input_source); if outcome.detach { let _ = write_to_server(write_stream, &ClientMessage::Detach); return Ok(true); @@ -1486,6 +588,7 @@ fn finish_client_shell_input( state.reported_size.1, state.reported_cell_size.0, state.reported_cell_size.1, + state.pixel_geometry_exact, ); write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?; } @@ -1493,7 +596,20 @@ fn finish_client_shell_input( if outcome.query_host_appearance { query_host_terminal_appearance(); } - dispatch_client_shell_actions(outcome.actions, endpoint_commands, write_stream)?; + if outcome.query_host_theme { + query_host_terminal_theme(); + } + sync_client_shell_keyboard_report_all(state)?; + let replay = dispatch_client_shell_actions( + outcome.actions, + endpoint_commands, + write_stream, + &mut state.detached_process_children, + )?; + debug_assert!( + replay.is_empty(), + "mouse replay only follows endpoint results" + ); for request in outcome.requests { write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; } @@ -1516,6 +632,7 @@ async fn run_client_loop( rows: u16, initial_cell_width_px: u32, initial_cell_height_px: u32, + initial_pixel_geometry_exact: bool, should_quit: Arc, config: ClientLoopConfig, negotiated_encoding: RenderEncoding, @@ -1529,11 +646,19 @@ async fn run_client_loop( let mut state = ClientState { blit_encoder: render_ansi::BlitEncoder::new(), mouse_capture_active: config.mouse_capture_active, + endpoint_mouse_capture_requested: false, + endpoint_sgr_pixels_requested: false, + direct_mouse_capture_preference: attach_escape.is_some() && config.mouse_capture_active, + shell_mouse_capture_preference: config.mouse_capture_active, + direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState::default(), + pane_keyboard_report_all: false, keyboard_report_all_active: false, reported_size: (cols, rows), reported_cell_size: (initial_cell_width_px, initial_cell_height_px), sound_config: config.sound_config, kitty_graphics_enabled: config.kitty_graphics_enabled, + pixel_geometry_enabled: config.pixel_geometry_enabled, + pixel_geometry_exact: initial_pixel_geometry_exact, #[cfg(unix)] direct_graphics_response: Arc::new(Mutex::new(direct_graphics::ResponseMatcher::default())), #[cfg(unix)] @@ -1547,6 +672,7 @@ async fn run_client_loop( redraw_on_focus_gained: config.redraw_on_focus_gained, repaint_pending: false, draw_host_cursor, + detached_process_children: Vec::new(), shell: config.shell_config.map(shell::ClientShellState::new), }; if let Some(shell) = state.shell.as_mut() { @@ -1565,9 +691,8 @@ async fn run_client_loop( let mut endpoint_commands = endpoint_commands::EndpointCommands::default(); // Spawn the stdin reader thread. - let will_query_host_terminal_theme = state.attach_escape.is_none() - && state.shell.is_none() - && should_query_host_terminal_theme(); + let will_query_host_terminal_theme = + state.attach_escape.is_none() && should_query_host_terminal_theme(); // Terminals behind ConPTY report no pixel size through the ioctl, so ask the // host terminal directly instead of falling back to an assumed cell size. let will_query_host_cell_size = state.attach_escape.is_none() @@ -1600,6 +725,10 @@ async fn run_client_loop( if will_query_host_terminal_theme { query_host_terminal_theme(); + #[cfg(not(windows))] + if state.shell.is_some() { + query_host_terminal_appearance(); + } } if will_query_host_cell_size { @@ -1611,6 +740,8 @@ async fn run_client_loop( let resize_tx = event_tx.clone(); let resize_cell_size = reported_cell_size.clone(); let kitty_graphics_enabled = state.kitty_graphics_enabled; + let pixel_geometry_enabled = state.pixel_geometry_enabled; + let pixel_geometry_fallback = config.pixel_geometry_fallback; std::thread::spawn(move || { resize_poll_loop( resize_tx, @@ -1618,7 +749,9 @@ async fn run_client_loop( rows, initial_cell_width_px, initial_cell_height_px, - kitty_graphics_enabled, + initial_pixel_geometry_exact, + pixel_geometry_enabled, + pixel_geometry_fallback, &resize_cell_size, &resize_quit, ); @@ -1652,7 +785,6 @@ async fn run_client_loop( // This (foreground) client owns the prefix ASCII input-source switch // (implemented on macOS and Windows; a no-op on other platforms). - use crate::platform::PrefixInputSource; let mut prefix_input_source = crate::platform::RealPrefixInputSource::default(); // Main event loop. @@ -1676,6 +808,48 @@ async fn run_client_loop( #[cfg(unix)] ClientLoopEvent::StdinInput(data) => { if state.shell.is_some() { + if will_query_host_cell_size { + let events = crate::raw_input::parse_raw_input_bytes_sync(&data); + if let Some((width_px, height_px)) = reported_cell_size_from_events(&events) + { + store_reported_cell_size(&reported_cell_size, width_px, height_px); + } + } + let image_target = state + .shell + .as_ref() + .and_then(|shell| shell.clipboard_image_target()); + if let Some(target) = image_target.clone() { + if should_bridge_clipboard_image_paste( + &data, + is_remote_client, + state.remote_image_paste_key, + ) { + if let Some(image) = crate::platform::read_clipboard_image() { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "clipboard paste", + )?; + continue; + } + info!( + "clipboard image paste trigger received, but local clipboard has no image" + ); + } + if let Some(image) = + read_image_file_from_terminal_drop(&data, is_remote_client) + { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "file drop", + )?; + continue; + } + } let (outcome, frame) = { let shell = state.shell.as_mut().expect("checked shell mode"); let outcome = shell.handle_input_bytes(&data); @@ -1691,6 +865,7 @@ async fn run_client_loop( frame, &mut write_stream, &mut endpoint_commands, + &mut prefix_input_source, )? { return Ok(()); } @@ -1703,30 +878,33 @@ async fn run_client_loop( state.mouse_scroll_lines, ) { AttachInputAction::Forward(data) => data, - AttachInputAction::ForwardAfterPendingPrefix(data) => { - let prefix = ClientMessage::Input { data: vec![0x02] }; - if let Err(e) = write_to_server(&mut write_stream, &prefix) { + AttachInputAction::ForwardPair(first, second) => { + for data in [first, second] { + if let Err(e) = write_to_server( + &mut write_stream, + &ClientMessage::Input { data }, + ) { + return Err(ClientError::ConnectionLost(e)); + } + } + continue; + } + AttachInputAction::Semantic(action) => { + if let Err(e) = write_attach_semantic_action(&mut write_stream, action) + { return Err(ClientError::ConnectionLost(e)); } - data + continue; } - AttachInputAction::Scroll { - source, - direction, - lines, - column, - row, - modifiers, - } => { - let msg = ClientMessage::AttachScroll { - source, - direction, - lines, - column, - row, - modifiers, - }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { + AttachInputAction::ForwardThenSemantic(prefix, action) => { + if let Err(e) = write_to_server( + &mut write_stream, + &ClientMessage::Input { data: prefix }, + ) { + return Err(ClientError::ConnectionLost(e)); + } + if let Err(e) = write_attach_semantic_action(&mut write_stream, action) + { return Err(ClientError::ConnectionLost(e)); } continue; @@ -1762,7 +940,12 @@ async fn run_client_loop( state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { - write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?; + write_remote_image_to_server( + &mut write_stream, + crate::protocol::ClientClipboardImageTarget::DirectTerminal, + image, + "clipboard paste", + )?; continue; } info!( @@ -1770,7 +953,12 @@ async fn run_client_loop( ); } if let Some(image) = read_image_file_from_terminal_drop(&data, is_remote_client) { - write_remote_image_to_server(&mut write_stream, image, "file drop")?; + write_remote_image_to_server( + &mut write_stream, + crate::protocol::ClientClipboardImageTarget::DirectTerminal, + image, + "file drop", + )?; continue; } let msg = ClientMessage::Input { data }; @@ -1821,25 +1009,80 @@ async fn run_client_loop( frame, &mut write_stream, &mut endpoint_commands, + &mut prefix_input_source, )? { return Ok(()); } continue; } - let message = ClientMessage::InputPixels { - data, - cols: geometry.cols, - rows: geometry.rows, - width_px: geometry.width_px, - height_px: geometry.height_px, - }; - if let Err(err) = write_to_server(&mut write_stream, &message) { - return Err(ClientError::ConnectionLost(err)); + if let Some(attach_escape) = state.attach_escape.as_mut() { + if let Some(prefix) = attach_escape.take_pending_prefix() { + if let Err(err) = write_to_server( + &mut write_stream, + &ClientMessage::Input { data: prefix }, + ) { + return Err(ClientError::ConnectionLost(err)); + } + } + if let Some((kind, position, modifiers)) = + direct_attach_pixel_mouse(&data, geometry) + { + let message = ClientMessage::AttachMouse { + kind, + position, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: geometry.cols, + rows: geometry.rows, + width_px: geometry.width_px, + height_px: geometry.height_px, + }), + modifiers, + lines: state.mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, + }; + if let Err(err) = write_to_server(&mut write_stream, &message) { + return Err(ClientError::ConnectionLost(err)); + } + } } } #[cfg(windows)] ClientLoopEvent::StdinEvents(events) => { if state.shell.is_some() { + let image_target = state + .shell + .as_ref() + .and_then(|shell| shell.clipboard_image_target()); + if let Some(target) = image_target.clone() { + if should_bridge_clipboard_image_events( + &events, + is_remote_client, + state.remote_image_paste_key, + ) { + if let Some(image) = crate::platform::read_clipboard_image() { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "clipboard paste", + )?; + continue; + } + info!( + "clipboard image paste trigger received, but local clipboard has no image" + ); + } + if let Some(image) = + read_image_file_from_client_events(&events, is_remote_client) + { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "file drop", + )?; + continue; + } + } let (outcome, frame) = { let shell = state.shell.as_mut().expect("checked shell mode"); let outcome = shell.handle_client_events(&events); @@ -1855,49 +1098,29 @@ async fn run_client_loop( frame, &mut write_stream, &mut endpoint_commands, + &mut prefix_input_source, )? { return Ok(()); } continue; } - if state.attach_escape.is_some() { - continue; - } - if should_bridge_clipboard_image_events( - &events, - is_remote_client, - state.remote_image_paste_key, - ) { - if let Some(image) = crate::platform::read_clipboard_image() { - write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?; - continue; - } - info!( - "clipboard image paste trigger received, but local clipboard has no image" - ); - } - if let Some(image) = read_image_file_from_client_events(&events, is_remote_client) { - write_remote_image_to_server(&mut write_stream, image, "file drop")?; - continue; - } - let raw_events = events - .iter() - .map(crate::protocol::ClientInputEvent::to_raw_input_event) - .collect::>(); - if crate::raw_input::events_require_host_surface_redraw( - &raw_events, - state.redraw_on_focus_gained, - ) { - state.request_repaint(); - } - let msg = ClientMessage::InputEvents { events }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { - return Err(ClientError::ConnectionLost(e)); - } + // Direct terminal attach is Unix-only; every Windows client uses ClientShell. } - ClientLoopEvent::Resize(new_cols, new_rows, cell_width_px, cell_height_px) => { + ClientLoopEvent::Resize( + new_cols, + new_rows, + cell_width_px, + cell_height_px, + pixel_geometry_exact, + ) => { + if !pixel_geometry_exact && host_sgr_pixels_active.load(Ordering::Acquire) { + set_mouse_capture(state.mouse_capture_active, false) + .map_err(ClientError::ConnectionFailed)?; + host_sgr_pixels_active.store(false, Ordering::Release); + } state.reported_size = (new_cols, new_rows); state.reported_cell_size = (cell_width_px, cell_height_px); + state.pixel_geometry_exact = pixel_geometry_exact; // Resizing invalidates both the host-side blit baseline and pane hit geometry. state.request_repaint(); if let Some(shell) = state.shell.as_mut() { @@ -1911,6 +1134,7 @@ async fn run_client_loop( new_rows, cell_width_px, cell_height_px, + pixel_geometry_exact, ) } else { ClientMessage::Resize { @@ -1918,6 +1142,7 @@ async fn run_client_loop( rows: new_rows, cell_width_px, cell_height_px, + pixel_mouse: pixel_geometry_exact, } }; if let Err(e) = write_to_server(&mut write_stream, &msg) { @@ -1925,7 +1150,6 @@ async fn run_client_loop( } } ClientLoopEvent::ServerMessage(msg) => match *msg { - ServerMessage::Frame(frame_data) => state.present_frame(frame_data), ServerMessage::ClientShellSnapshot(snapshot) => { let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell { @@ -1944,6 +1168,7 @@ async fn run_client_loop( state.reported_size.1, state.reported_cell_size.0, state.reported_cell_size.1, + state.pixel_geometry_exact, ) }), graphics_cleanup, @@ -1951,6 +1176,7 @@ async fn run_client_loop( } else { (None, None, Vec::new()) }; + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); state.present_graphics(&graphics_cleanup); if let Some(resize) = resize { if let Err(err) = write_to_server(&mut write_stream, &resize) { @@ -1968,6 +1194,7 @@ async fn run_client_loop( } else { None }; + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); if let Some(frame) = composed { state.present_frame(frame); } @@ -2183,21 +1410,62 @@ async fn run_client_loop( ) }, ); - dispatch_client_shell_actions( + if let Some(shell) = state.shell.as_mut() { + shell.reconcile_input_source(); + } + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + let replay_mouse = dispatch_client_shell_actions( actions, &mut endpoint_commands, &mut write_stream, + &mut state.detached_process_children, )?; - if repaint { - if let Some(frame) = state.shell.as_mut().and_then(|shell| { - shell.compose(state.reported_size.0, state.reported_size.1) - }) { - state.present_frame(frame); + if replay_mouse.is_empty() { + if repaint { + if let Some(frame) = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) { + state.present_frame(frame); + } + } + } else { + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("shell endpoint response"); + let mut outcome = shell.replay_mouse_events(replay_mouse); + outcome.repaint |= repaint; + let frame = outcome + .repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); } } } ServerMessage::Clipboard { data } => { - forward_clipboard(&data); + if forward_clipboard(&data) { + let (width, height) = state.reported_size; + let frame = state.shell.as_mut().and_then(|shell| { + shell + .show_copy_feedback(std::time::Instant::now()) + .then(|| shell.compose(width, height)) + .flatten() + }); + if let Some(frame) = frame { + state.present_frame(frame); + } + } let _ = io::stdout().flush(); } ServerMessage::WindowTitle { title } => { @@ -2207,12 +1475,47 @@ async fn run_client_loop( ); } ServerMessage::ReloadSoundConfig => { + let previous_mouse_capture = state.shell_mouse_capture_preference; + let mut mouse_capture = previous_mouse_capture; reload_local_client_config( &mut state.sound_config, &mut state.redraw_on_focus_gained, &mut state.draw_host_cursor, &mut state.remote_image_paste_key, + &mut mouse_capture, ); + state.shell_mouse_capture_preference = mouse_capture; + state.direct_mouse_capture_preference = + state.attach_escape.is_some() && mouse_capture; + if state.shell.is_some() && previous_mouse_capture != mouse_capture { + write_to_server( + &mut write_stream, + &ClientMessage::ClientShellMouseCapture { + enabled: mouse_capture, + }, + ) + .map_err(ClientError::ConnectionLost)?; + } + if state.attach_escape.is_some() { + let enabled = effective_mouse_capture( + state.endpoint_mouse_capture_requested, + state.direct_mouse_capture_preference, + ); + let sgr_pixels = effective_sgr_pixel_mouse( + enabled, + state.endpoint_sgr_pixels_requested, + state.pixel_geometry_exact, + ); + if enabled != state.mouse_capture_active + || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire) + { + set_mouse_capture(enabled, sgr_pixels) + .map_err(ClientError::ConnectionFailed)?; + } + state.mouse_capture_active = enabled; + host_mouse_capture_active.store(enabled, Ordering::Release); + host_sgr_pixels_active.store(sgr_pixels, Ordering::Release); + } let (frame, resize) = if let Some(shell) = state.shell.as_mut() { let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1); @@ -2227,6 +1530,7 @@ async fn run_client_loop( state.reported_size.1, state.reported_cell_size.0, state.reported_cell_size.1, + state.pixel_geometry_exact, ) }); ( @@ -2236,6 +1540,7 @@ async fn run_client_loop( } else { (None, None) }; + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); if let Some(resize) = resize { write_to_server(&mut write_stream, &resize) .map_err(ClientError::ConnectionLost)?; @@ -2248,7 +1553,12 @@ async fn run_client_loop( enabled, sgr_pixels, } => { - let next_sgr_pixels = enabled && sgr_pixels; + state.endpoint_mouse_capture_requested = enabled; + state.endpoint_sgr_pixels_requested = sgr_pixels; + let enabled = + effective_mouse_capture(enabled, state.direct_mouse_capture_preference); + let next_sgr_pixels = + effective_sgr_pixel_mouse(enabled, sgr_pixels, state.pixel_geometry_exact); let mouse_mode_changed = enabled != state.mouse_capture_active || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire); if mouse_mode_changed { @@ -2267,21 +1577,24 @@ async fn run_client_loop( host_mouse_capture_active.store(enabled, Ordering::Release); host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release); } - ServerMessage::KittyKeyboardReportAll { enabled } => { - if enabled != state.keyboard_report_all_active { - crate::terminal_modes::set_host_kitty_keyboard_report_all( + ServerMessage::DirectTerminalKeyboardProtocol { + flags, + modify_other_keys_level, + } => { + if state.attach_escape.is_some() { + crate::terminal_modes::set_direct_host_keyboard_protocol( &mut io::stdout(), - enabled, + &mut state.direct_keyboard_protocol, + flags, + modify_other_keys_level, ) .map_err(ClientError::ConnectionFailed)?; - state.keyboard_report_all_active = enabled; } } - ServerMessage::PrefixInputSource { active } => { - if active { - prefix_input_source.switch_to_ascii(); - } else { - prefix_input_source.restore(); + ServerMessage::ClientShellKeyboardReportAll { enabled } => { + if state.shell.is_some() { + state.pane_keyboard_report_all = enabled; + sync_client_shell_keyboard_report_all(&mut state)?; } } ServerMessage::Welcome { .. } => { @@ -2299,6 +1612,9 @@ async fn run_client_loop( if let Ok(mut matcher) = state.direct_graphics_response.lock() { matcher.expire(); } + state + .detached_process_children + .retain_mut(|child| child.try_wait().ok().flatten().is_none()); if state.shell.is_some() { let (effects, outcome, frame) = { let shell = state.shell.as_mut().expect("checked shell mode"); @@ -2318,6 +1634,7 @@ async fn run_client_loop( frame, &mut write_stream, &mut endpoint_commands, + &mut prefix_input_source, )? { return Ok(()); } @@ -2398,57 +1715,10 @@ fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result< protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string())) } -fn write_remote_image_to_server( - stream: &mut LocalStream, - image: crate::platform::ClipboardImage, - source: &'static str, -) -> Result<(), ClientError> { - if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { - warn!( - bytes = image.bytes.len(), - max = MAX_CLIPBOARD_IMAGE_PAYLOAD, - source, - "local image is too large to bridge" - ); - return Ok(()); - } - - info!( - bytes = image.bytes.len(), - extension = image.extension, - source, - "bridging local image to remote server" - ); - write_to_server( - stream, - &ClientMessage::ClipboardImage { - extension: image.extension.to_owned(), - data: image.bytes, - }, - ) - .map_err(ClientError::ConnectionLost) -} - // --------------------------------------------------------------------------- // Notifications // --------------------------------------------------------------------------- -fn client_remote_image_paste_key( - config: &crate::config::Config, -) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { - if !is_remote_client_process() { - return None; - } - - match config.remote_image_paste_key() { - Ok(key) => key, - Err(diagnostic) => { - warn!(diagnostic = %diagnostic, "local remote image paste key config diagnostic"); - None - } - } -} - fn reload_local_client_config( sound_config: &mut crate::config::SoundConfig, redraw_on_focus_gained: &mut bool, @@ -2457,17 +1727,29 @@ fn reload_local_client_config( crossterm::event::KeyCode, crossterm::event::KeyModifiers, )>, + mouse_capture: &mut bool, ) { match crate::config::load_live_config() { Ok(loaded) => { - for diagnostic in loaded.config.ui.sound.diagnostics() { - warn!(diagnostic = %diagnostic, "local sound config diagnostic"); + let invalid_section = |section: &str| { + loaded + .invalid_sections + .iter() + .any(|invalid| invalid == section) + }; + if !invalid_section("ui") && loaded.config.invalid_sidebar_bounds_diagnostic().is_none() + { + for diagnostic in loaded.config.ui.sound.diagnostics() { + warn!(diagnostic = %diagnostic, "local sound config diagnostic"); + } + *sound_config = loaded.config.ui.sound.clone(); + *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; + *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); + *mouse_capture = loaded.config.ui.mouse_capture; + } + if !invalid_section("keys") { + *remote_image_paste_key = client_remote_image_paste_key(&loaded.config); } - let loaded_remote_image_paste_key = client_remote_image_paste_key(&loaded.config); - *sound_config = loaded.config.ui.sound; - *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; - *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); - *remote_image_paste_key = loaded_remote_image_paste_key; debug!("reloaded local client config"); } Err(diagnostics) => { @@ -2476,293 +1758,6 @@ fn reload_local_client_config( } } -fn handle_shell_notification_effects( - effects: Vec, - sound_config: &crate::config::SoundConfig, -) { - for effect in effects { - match effect { - shell::ClientShellNotificationEffect::Sound { sound, agent } => { - let agent = agent.as_deref().and_then(crate::detect::parse_agent_label); - if sound_config.allows(agent) { - crate::sound::play(sound, sound_config); - } - } - shell::ClientShellNotificationEffect::Terminal { title, body } => { - if let Err(err) = crate::terminal_notify::show_notification(&title, body.as_deref()) - { - warn!(err = %err, "failed to emit terminal notification"); - } - } - shell::ClientShellNotificationEffect::System { title, body } => { - if let Err(err) = - crate::platform::show_desktop_notification(&title, body.as_deref()) - { - warn!(err = %err, "failed to emit system notification"); - } - } - } - } -} - -fn handle_notify( - kind: NotifyKind, - message: &str, - body: Option<&str>, - sound_config: &crate::config::SoundConfig, -) { - handle_notify_with_notifiers( - kind, - message, - body, - sound_config, - crate::terminal_notify::show_notification, - crate::platform::show_desktop_notification, - ); -} - -fn handle_notify_with_notifiers( - kind: NotifyKind, - message: &str, - body: Option<&str>, - sound_config: &crate::config::SoundConfig, - mut show_terminal_notification: impl FnMut(&str, Option<&str>) -> io::Result, - mut show_system_notification: impl FnMut(&str, Option<&str>) -> io::Result, -) { - match kind { - NotifyKind::Sound => { - let Some(sound) = sound_from_notify_message(message) else { - warn!( - message = message, - "received unknown sound notification from server" - ); - return; - }; - if sound_config.enabled { - crate::sound::play(sound, sound_config); - } - } - NotifyKind::Toast => { - debug!( - message = message, - "received terminal toast notification from server" - ); - if let Err(err) = show_terminal_notification(message, body) { - warn!(err = %err, "failed to emit terminal notification"); - } - } - NotifyKind::SystemToast => { - debug!( - message = message, - "received system toast notification from server" - ); - if let Err(err) = show_system_notification(message, body) { - warn!(err = %err, "failed to emit system notification"); - } - } - } -} - -fn sound_from_notify_message(message: &str) -> Option { - match message { - "agent done" => Some(crate::sound::Sound::Done), - "agent attention" => Some(crate::sound::Sound::Request), - _ => None, - } -} - -#[cfg(unix)] -fn should_bridge_clipboard_image_paste( - data: &[u8], - is_remote_client: bool, - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, -) -> bool { - if data == b"\x1b[200~\x1b[201~" { - return is_remote_client; - } - - let Some(remote_image_paste_key) = remote_image_paste_key else { - return false; - }; - - let events = crate::raw_input::parse_raw_input_bytes_sync(data); - matches!( - events.as_slice(), - [crate::raw_input::RawInputEvent::Key(key)] - if key.kind == crossterm::event::KeyEventKind::Press - && crate::config::terminal_key_matches_combo(key, remote_image_paste_key) - ) -} - -#[cfg(any(windows, test))] -fn should_bridge_clipboard_image_events( - events: &[crate::protocol::ClientInputEvent], - is_remote_client: bool, - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, -) -> bool { - if !is_remote_client { - return false; - } - if matches!( - events, - [crate::protocol::ClientInputEvent::Paste { text }] if text.is_empty() - ) { - return true; - } - - let Some(remote_image_paste_key) = remote_image_paste_key else { - return false; - }; - matches!( - events, - [event] - if matches!( - event.to_raw_input_event(), - crate::raw_input::RawInputEvent::Key(key) - if key.kind == crossterm::event::KeyEventKind::Press - && crate::config::terminal_key_matches_combo( - &key, - remote_image_paste_key, - ) - ) - ) -} - -#[cfg(unix)] -fn read_image_file_from_terminal_drop( - data: &[u8], - is_remote_client: bool, -) -> Option { - let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?; - read_image_file(path, extension) -} - -#[cfg(any(windows, test))] -fn read_image_file_from_client_events( - events: &[crate::protocol::ClientInputEvent], - is_remote_client: bool, -) -> Option { - let [crate::protocol::ClientInputEvent::Paste { text }] = events else { - return None; - }; - let text = normalized_terminal_drop_text(text)?; - // Windows events already carry native paths; Unix backslash unescaping would corrupt them. - let (path, extension) = - image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?; - read_image_file(path, extension) -} - -fn read_image_file( - path: std::path::PathBuf, - extension: &'static str, -) -> Option { - let metadata = std::fs::metadata(&path).ok()?; - if !metadata.is_file() { - return None; - } - - let file = std::fs::File::open(&path).ok()?; - let bytes = - match crate::platform::read_limited_reader(file, MAX_CLIPBOARD_IMAGE_PAYLOAD).ok()? { - crate::platform::LimitedRead::Complete(bytes) => bytes, - crate::platform::LimitedRead::Empty => return None, - crate::platform::LimitedRead::Oversized => { - warn!( - max = MAX_CLIPBOARD_IMAGE_PAYLOAD, - "local image file drop is too large to bridge" - ); - return None; - } - }; - - Some(crate::platform::ClipboardImage { bytes, extension }) -} - -#[cfg(unix)] -fn image_path_from_terminal_drop( - data: &[u8], - is_remote_client: bool, -) -> Option<(std::path::PathBuf, &'static str)> { - let bytes = bracketed_paste_payload(data).unwrap_or(data); - let text = std::str::from_utf8(bytes).ok()?; - let text = normalized_terminal_drop_text(text)?; - let text = unescape_terminal_drop_path(strip_matching_path_quotes(text)); - image_path_from_drop_text(&text, is_remote_client) -} - -fn normalized_terminal_drop_text(text: &str) -> Option<&str> { - let text = text.trim_end_matches(['\r', '\n']); - (!text.is_empty() && !text.contains(['\r', '\n'])).then_some(text) -} - -fn image_path_from_drop_text( - text: &str, - is_remote_client: bool, -) -> Option<(std::path::PathBuf, &'static str)> { - if !is_remote_client { - return None; - } - let path = std::path::PathBuf::from(text); - if !path.is_absolute() { - return None; - } - let extension = recognized_image_extension(path.extension()?.to_str()?)?; - Some((path, extension)) -} - -#[cfg(unix)] -fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> { - const START: &[u8] = b"\x1b[200~"; - const END: &[u8] = b"\x1b[201~"; - data.strip_prefix(START)?.strip_suffix(END) -} - -fn strip_matching_path_quotes(text: &str) -> &str { - if text.len() < 2 { - return text; - } - - let bytes = text.as_bytes(); - match (bytes.first(), bytes.last()) { - (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"')) => &text[1..text.len() - 1], - _ => text, - } -} - -#[cfg(unix)] -fn unescape_terminal_drop_path(text: &str) -> String { - let mut unescaped = String::with_capacity(text.len()); - let mut chars = text.chars(); - while let Some(ch) = chars.next() { - if ch == '\\' { - if let Some(escaped) = chars.next() { - unescaped.push(escaped); - } else { - unescaped.push(ch); - } - } else { - unescaped.push(ch); - } - } - unescaped -} - -fn recognized_image_extension(extension: &str) -> Option<&'static str> { - if extension.eq_ignore_ascii_case("png") { - Some("png") - } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") { - Some("jpg") - } else if extension.eq_ignore_ascii_case("gif") { - Some("gif") - } else if extension.eq_ignore_ascii_case("webp") { - Some("webp") - } else if extension.eq_ignore_ascii_case("bmp") { - Some("bmp") - } else { - None - } -} - // --------------------------------------------------------------------------- // Clipboard forwarding // --------------------------------------------------------------------------- @@ -2774,308 +1769,14 @@ fn decode_clipboard_payload(data: &str) -> Option> { } /// Forwards a clipboard write from the server to the local client clipboard. -fn forward_clipboard(data: &str) { +fn forward_clipboard(data: &str) -> bool { let Some(bytes) = decode_clipboard_payload(data) else { warn!("received invalid clipboard payload from server"); - return; + return false; }; crate::selection::write_osc52_bytes(&bytes); -} - -// --------------------------------------------------------------------------- -// Frame output -// --------------------------------------------------------------------------- - -fn write_encoded_frame_with_graphics( - mut writer: impl io::Write, - encoded: &[u8], - graphics: &[u8], -) -> io::Result<()> { - if graphics.is_empty() { - return writer.write_all(encoded); - } - - let insertion = render_ansi::final_sync_output_end(encoded).unwrap_or(encoded.len()); - - writer.write_all(&encoded[..insertion])?; - record_received_kitty_graphics(graphics); - writer.write_all(b"\x1b7")?; - writer.write_all(graphics)?; - writer.write_all(b"\x1b8")?; - writer.write_all(&encoded[insertion..]) -} - -fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool { - bytes.windows(3).any(|window| window == b"\x1b_G") -} - -fn record_received_kitty_graphics(bytes: &[u8]) { - let ids = kitty_graphics_image_ids(bytes); - if ids.is_empty() { - return; - } - let set = RECEIVED_KITTY_GRAPHICS_IDS.get_or_init(|| Mutex::new(HashSet::new())); - if let Ok(mut set) = set.lock() { - set.extend(ids); - } -} - -fn clear_received_kitty_graphics(mut writer: impl io::Write) -> io::Result<()> { - let Some(set) = RECEIVED_KITTY_GRAPHICS_IDS.get() else { - return Ok(()); - }; - let Ok(mut set) = set.lock() else { - return Ok(()); - }; - for id in set.drain() { - write!(writer, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\")?; - } - writer.flush() -} - -fn kitty_graphics_image_ids(bytes: &[u8]) -> Vec { - let mut ids = Vec::new(); - let mut index = 0usize; - while let Some(start) = find_subslice(&bytes[index..], b"\x1b_G") { - let command_start = index + start + 3; - let Some(end) = find_subslice(&bytes[command_start..], b"\x1b\\") else { - break; - }; - let command = &bytes[command_start..command_start + end]; - if let Some(id) = kitty_graphics_command_image_id(command) { - ids.push(id); - } - index = command_start + end + 2; - } - ids -} - -fn kitty_graphics_command_image_id(command: &[u8]) -> Option { - let header_end = command - .iter() - .position(|byte| *byte == b';') - .unwrap_or(command.len()); - for part in command[..header_end].split(|byte| *byte == b',') { - let Some(value) = part.strip_prefix(b"i=") else { - continue; - }; - let text = std::str::from_utf8(value).ok()?; - if let Ok(id) = text.parse::() { - return Some(id); - } - } - None -} - -fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { - if needle.is_empty() || needle.len() > haystack.len() { - return None; - } - haystack - .windows(needle.len()) - .position(|window| window == needle) -} - -// --------------------------------------------------------------------------- -// Resize polling -// --------------------------------------------------------------------------- - -/// Cell size assumed when neither the terminal size ioctl nor the host -/// terminal reports pixel dimensions. -const DEFAULT_CELL_WIDTH_PX: u32 = 8; -const DEFAULT_CELL_HEIGHT_PX: u32 = 16; - -/// Cell size derived from the terminal size ioctl, if it reports pixels. -fn ioctl_cell_size() -> Option<(u32, u32)> { - let size = crossterm::terminal::window_size().ok()?; - if size.columns == 0 || size.rows == 0 || size.width == 0 || size.height == 0 { - return None; - } - Some(( - (size.width as u32 / size.columns as u32).max(1), - (size.height as u32 / size.rows as u32).max(1), - )) -} - -/// Cell size used when the ioctl reports no pixels. -fn cell_size_fallback(reported: u64, last: Option<(u32, u32)>) -> (u32, u32) { - unpack_cell_size(reported) - .or(last.filter(|(width, height)| *width > 0 && *height > 0)) - .unwrap_or((DEFAULT_CELL_WIDTH_PX, DEFAULT_CELL_HEIGHT_PX)) -} - -#[cfg(any(unix, test))] -fn pack_cell_size(width_px: u32, height_px: u32) -> u64 { - (u64::from(width_px) << 32) | u64::from(height_px) -} - -fn unpack_cell_size(packed: u64) -> Option<(u32, u32)> { - let width_px = (packed >> 32) as u32; - let height_px = (packed & u64::from(u32::MAX)) as u32; - (width_px > 0 && height_px > 0).then_some((width_px, height_px)) -} - -fn current_terminal_geometry( - kitty_graphics_enabled: bool, - reported_cell_size: &AtomicU64, - last_cell_size: Option<(u32, u32)>, -) -> (u16, u16, u32, u32) { - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); - if !kitty_graphics_enabled { - return (cols, rows, 0, 0); - } - let (cell_width_px, cell_height_px) = ioctl_cell_size().unwrap_or_else(|| { - cell_size_fallback(reported_cell_size.load(Ordering::Acquire), last_cell_size) - }); - (cols, rows, cell_width_px, cell_height_px) -} - -/// Reads terminal geometry before the handshake. Direct graphics is eligible -/// only when the host supplied exact pixel dimensions through the ioctl. -fn initial_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32, bool) { - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); - if !kitty_graphics_enabled { - return (cols, rows, 0, 0, false); - } - match ioctl_cell_size() { - Some((width, height)) => (cols, rows, width, height, true), - None => ( - cols, - rows, - DEFAULT_CELL_WIDTH_PX, - DEFAULT_CELL_HEIGHT_PX, - false, - ), - } -} - -/// Reports polled changes and signalled resizes that return to the same size. -fn resize_report_required( - signalled: bool, - new_size: (u16, u16, u32, u32), - last_size: (u16, u16, u32, u32), -) -> bool { - signalled || new_size != last_size -} - -/// Watches the terminal size and sends resize events when it changes. -/// -/// The baseline cell size must match what the handshake sent to the server: -/// reading a fresh one here would race the host cell size reply and could -/// swallow the first change. -fn resize_poll_loop( - resize_tx: tokio::sync::mpsc::Sender, - initial_cols: u16, - initial_rows: u16, - initial_cell_width: u32, - initial_cell_height: u32, - kitty_graphics_enabled: bool, - reported_cell_size: &AtomicU64, - should_quit: &Arc, -) { - crate::platform::watch_terminal_resize_signal(); - let mut last_size = ( - initial_cols, - initial_rows, - initial_cell_width, - initial_cell_height, - ); - while !should_quit.load(Ordering::Acquire) { - std::thread::sleep(Duration::from_millis(100)); - let signalled = crate::platform::take_terminal_resize_signal(); - let new_size = current_terminal_geometry( - kitty_graphics_enabled, - reported_cell_size, - Some((last_size.2, last_size.3)), - ); - if resize_report_required(signalled, new_size, last_size) { - last_size = new_size; - if resize_tx - .blocking_send(ClientLoopEvent::Resize( - new_size.0, new_size.1, new_size.2, new_size.3, - )) - .is_err() - { - break; // Main loop gone. - } - } - } -} - -// --------------------------------------------------------------------------- -// Logging -// --------------------------------------------------------------------------- - -#[cfg(any(not(windows), test))] -fn query_host_terminal_appearance() { - let _ = write_host_terminal_appearance_query(io::stdout()); -} - -#[cfg(any(not(windows), test))] -fn write_host_terminal_appearance_query(mut writer: impl io::Write) -> io::Result<()> { - writer.write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())?; - writer.flush() -} - -/// Initialize logging for the client process. -fn query_host_terminal_theme() { - let _ = write_host_terminal_theme_query(io::stdout()); -} - -fn should_query_host_terminal_theme() -> bool { - !cfg!(windows) -} - -fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> { - let query = crate::terminal_theme::host_terminal_theme_query_sequence( - crate::platform::should_query_host_terminal_palette(), - ); - writer.write_all(query.as_bytes())?; - writer.flush() -} - -/// XTWINOPS request for the host terminal cell size in pixels. -const HOST_CELL_SIZE_QUERY: &[u8] = b"\x1b[16t"; - -fn query_host_cell_size() { - let _ = write_host_cell_size_query(io::stdout()); -} - -fn should_query_host_cell_size() -> bool { - !cfg!(windows) -} - -/// Only pane graphics need pixel dimensions, and only when the ioctl cannot -/// provide them. -fn host_cell_size_query_required(kitty_graphics_enabled: bool) -> bool { - kitty_graphics_enabled && should_query_host_cell_size() && ioctl_cell_size().is_none() -} - -fn write_host_cell_size_query(mut writer: impl io::Write) -> io::Result<()> { - writer.write_all(HOST_CELL_SIZE_QUERY)?; - writer.flush() -} - -#[cfg(any(unix, test))] -fn store_reported_cell_size(reported_cell_size: &AtomicU64, width_px: u32, height_px: u32) { - let packed = pack_cell_size(width_px, height_px); - if reported_cell_size.swap(packed, Ordering::AcqRel) != packed { - debug!(width_px, height_px, "host terminal reported cell size"); - } -} - -#[cfg(any(unix, test))] -fn reported_cell_size_from_events( - events: &[crate::raw_input::RawInputEvent], -) -> Option<(u32, u32)> { - events.iter().rev().find_map(|event| match event { - crate::raw_input::RawInputEvent::HostCellSizeReport { - width_px, - height_px, - } => Some((*width_px, *height_px)), - _ => None, - }) + true } fn init_logging() { @@ -3087,979 +1788,4 @@ fn init_logging() { // --------------------------------------------------------------------------- #[cfg(test)] -mod tests { - use super::*; - use std::ffi::OsString; - use std::sync::{Mutex, OnceLock}; - - fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - #[test] - fn resize_signal_reports_even_when_polled_size_is_unchanged() { - let size = (120, 40, 8, 16); - assert!(resize_report_required(true, size, size)); - assert!(!resize_report_required(false, size, size)); - assert!(resize_report_required(false, (120, 41, 8, 16), size)); - assert!(resize_report_required(false, (120, 40, 9, 18), size)); - } - - #[test] - fn direct_graphics_profile_is_narrow_and_transport_safe() { - for (program, term, kitty, expected) in [ - ("ghostty", "", false, true), - ("WezTerm", "", false, true), - ("", "xterm-kitty", false, true), - ("", "xterm-256color", true, true), - ("", "xterm-256color", false, false), - ] { - assert_eq!( - direct_graphics_profile_values(program, term, kitty, false, true), - expected - ); - } - assert!(!direct_graphics_profile_values( - "ghostty", "", false, true, true - )); - assert!(!direct_graphics_profile_values( - "ghostty", "", false, false, false - )); - } - - fn restore_env_var(key: &str, value: Option) { - if let Some(value) = value { - std::env::set_var(key, value); - } else { - std::env::remove_var(key); - } - } - - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - restore_env_var(self.key, self.previous.clone()); - } - } - - #[test] - fn windows_virtual_terminal_input_mode_sets_only_vti_bit() { - assert_eq!(windows_virtual_terminal_input_mode(0x01f0), 0x03f0); - assert_eq!(windows_virtual_terminal_input_mode(0x03f0), 0x03f0); - } - - struct EnvVarsRemovedGuard { - previous: Vec<(&'static str, Option)>, - } - - impl EnvVarsRemovedGuard { - fn new(keys: &[&'static str]) -> Self { - let previous: Vec<_> = keys - .iter() - .map(|key| (*key, std::env::var_os(key))) - .collect(); - for key in keys { - std::env::remove_var(key); - } - Self { previous } - } - } - - impl Drop for EnvVarsRemovedGuard { - fn drop(&mut self) { - for (key, value) in self.previous.clone() { - restore_env_var(key, value); - } - } - } - - #[test] - fn remote_client_uses_extended_handshake_timeout() { - let _guard = env_lock().lock().unwrap(); - let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local"); - - assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT); - } - - #[test] - fn host_cursor_policy_auto_uses_platform_default() { - assert_eq!( - should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto), - crate::platform::should_draw_host_cursor_by_default() - ); - } - - #[test] - fn host_cursor_policy_native_and_drawn_override_auto_detection() { - let _guard = env_lock().lock().unwrap(); - let _env = EnvVarGuard::set("TERM_PROGRAM", "WezTerm"); - - assert!(!should_draw_host_cursor( - crate::config::HostCursorModeConfig::Native - )); - assert!(should_draw_host_cursor( - crate::config::HostCursorModeConfig::Drawn - )); - } - - #[cfg(unix)] - #[test] - fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { - let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); - assert!(should_bridge_clipboard_image_paste( - &[0x16], - true, - Some(ctrl_v) - )); - assert!(should_bridge_clipboard_image_paste( - b"\x1b[118;5u", - true, - Some(ctrl_v) - )); - assert!(should_bridge_clipboard_image_paste( - b"\x1b[200~\x1b[201~", - true, - None - )); - assert!(!should_bridge_clipboard_image_paste( - b"\x1b[200~\x1b[201~", - false, - Some(ctrl_v) - )); - assert!(!should_bridge_clipboard_image_paste( - b"\x1b[200~text\x1b[201~", - true, - Some(ctrl_v) - )); - assert!(!should_bridge_clipboard_image_paste(&[0x16], true, None)); - assert!(!should_bridge_clipboard_image_paste( - b"v", - true, - Some(ctrl_v) - )); - } - - struct TempImageFile { - path: std::path::PathBuf, - } - - impl TempImageFile { - fn new(extension: &str, bytes: &[u8]) -> Self { - Self::with_name_fragment("test", extension, bytes) - } - - fn with_name_fragment(name_fragment: &str, extension: &str, bytes: &[u8]) -> Self { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "herdr-client-drop-{name_fragment}-{}-{nanos}.{extension}", - std::process::id() - )); - std::fs::write(&path, bytes).unwrap(); - Self { path } - } - } - - impl Drop for TempImageFile { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.path); - } - } - - #[test] - fn clipboard_image_event_bridge_matches_remote_key_and_empty_paste() { - let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); - let key = crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('v'), - modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }; - let empty_paste = crate::protocol::ClientInputEvent::Paste { - text: String::new(), - }; - - assert!(should_bridge_clipboard_image_events( - std::slice::from_ref(&key), - true, - Some(ctrl_v), - )); - assert!(should_bridge_clipboard_image_events( - std::slice::from_ref(&empty_paste), - true, - None, - )); - assert!(!should_bridge_clipboard_image_events( - &[key], - false, - Some(ctrl_v), - )); - assert!(!should_bridge_clipboard_image_events( - &[crate::protocol::ClientInputEvent::Paste { - text: "text".to_string(), - }], - true, - Some(ctrl_v), - )); - } - - #[test] - fn remote_image_file_drop_bridge_reads_semantic_paste_path() { - let file = TempImageFile::new("PNG", b"image-bytes"); - let events = [crate::protocol::ClientInputEvent::Paste { - text: format!("\"{}\"", file.path.display()), - }]; - - let image = read_image_file_from_client_events(&events, true).unwrap(); - - assert_eq!(image.extension, "png"); - assert_eq!(image.bytes, b"image-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() { - let file = TempImageFile::new("PNG", b"image-bytes"); - let input = format!("\x1b[200~{}\x1b[201~", file.path.display()); - - let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); - - assert_eq!(image.extension, "png"); - assert_eq!(image.bytes, b"image-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_reads_plain_quoted_path_with_newline() { - let file = TempImageFile::new("jpeg", b"jpeg-bytes"); - let input = format!("'{}'\n", file.path.display()); - - let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); - - assert_eq!(image.extension, "jpg"); - assert_eq!(image.bytes, b"jpeg-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_unescapes_spaces_in_paths() { - let file = TempImageFile::with_name_fragment("space test", "png", b"image-bytes"); - let escaped_path = file.path.display().to_string().replace(' ', "\\ "); - - let image = read_image_file_from_terminal_drop(escaped_path.as_bytes(), true).unwrap(); - - assert_eq!(image.extension, "png"); - assert_eq!(image.bytes, b"image-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_ignores_non_remote_and_non_image_input() { - let file = TempImageFile::new("png", b"image-bytes"); - let path = file.path.display().to_string(); - - assert!(read_image_file_from_terminal_drop(path.as_bytes(), false).is_none()); - assert!(read_image_file_from_terminal_drop(b"relative.png\n", true).is_none()); - assert!(read_image_file_from_terminal_drop(b"/tmp/file.txt\n", true).is_none()); - assert!(read_image_file_from_terminal_drop( - format!("{}\nextra", file.path.display()).as_bytes(), - true - ) - .is_none()); - } - - #[test] - fn graphics_bytes_are_written_inside_synchronized_blit_with_saved_cursor() { - let mut output = Vec::new(); - write_encoded_frame_with_graphics( - &mut output, - b"\x1b[?2026htext\x1b[?2026lcursor", - b"graphics", - ) - .unwrap(); - - assert_eq!( - output, - b"\x1b[?2026htext\x1b7graphics\x1b8\x1b[?2026lcursor" - ); - } - - #[test] - fn empty_graphics_writes_only_blit_frame() { - let mut output = Vec::new(); - write_encoded_frame_with_graphics(&mut output, b"text", b"").unwrap(); - - assert_eq!(output, b"text"); - } - - #[test] - fn terminal_frame_kitty_detection_matches_apc_prefix() { - assert!(contains_kitty_graphics_bytes(b"text\x1b_Ga=p;\x1b\\")); - assert!(!contains_kitty_graphics_bytes(b"text\x1b[?2026h")); - } - - #[test] - fn kitty_graphics_image_id_parser_tracks_herdr_ids_only() { - let ids = kitty_graphics_image_ids( - b"text\x1b_Ga=t,t=d,f=32,s=1,v=1,i=10023,q=2;AAAA\x1b\\\x1b_Ga=p,i=10023,p=7;\x1b\\", - ); - assert_eq!(ids, vec![10023, 10023]); - } - - #[test] - fn kitty_graphics_cleanup_deletes_tracked_images_not_all_images() { - record_received_kitty_graphics(b"\x1b_Ga=t,i=123,q=2;AAAA\x1b\\"); - let mut output = Vec::new(); - clear_received_kitty_graphics(&mut output).unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("a=d,d=I,i=123")); - assert!(!text.contains("d=A")); - } - - #[test] - fn write_host_terminal_appearance_query_emits_mode_2031_query() { - let mut output = Vec::new(); - write_host_terminal_appearance_query(&mut output).unwrap(); - assert_eq!(output, b"\x1b[?996n"); - } - - #[test] - fn write_host_terminal_theme_query_emits_osc_queries() { - let mut output = Vec::new(); - write_host_terminal_theme_query(&mut output).unwrap(); - assert_eq!( - output, - crate::terminal_theme::host_terminal_theme_query_sequence( - crate::platform::should_query_host_terminal_palette(), - ) - .as_bytes() - ); - assert!(!output - .windows(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.len()) - .any(|window| window - == crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())); - } - - #[test] - fn write_host_color_scheme_report_mode_emits_mode_sequences() { - let mut output = Vec::new(); - write_host_color_scheme_report_mode(&mut output, true).unwrap(); - write_host_color_scheme_report_mode(&mut output, false).unwrap(); - - let mut expected = Vec::new(); - expected.extend_from_slice( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE.as_bytes(), - ); - expected.extend_from_slice( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), - ); - assert_eq!(output, expected); - } - - #[test] - fn color_scheme_change_event_requests_host_theme_query() { - let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); - - assert!(crate::raw_input::events_require_host_terminal_theme_query( - &events - )); - } - - #[test] - fn host_terminal_theme_query_is_disabled_on_windows() { - assert_eq!(should_query_host_terminal_theme(), !cfg!(windows)); - } - - #[test] - fn write_host_cell_size_query_emits_xtwinops_request() { - let mut output = Vec::new(); - write_host_cell_size_query(&mut output).unwrap(); - - assert_eq!(output, b"\x1b[16t"); - } - - #[test] - fn host_cell_size_query_is_disabled_on_windows() { - assert_eq!(should_query_host_cell_size(), !cfg!(windows)); - } - - #[test] - fn cell_size_fallback_prefers_reported_then_previous_size() { - assert_eq!(cell_size_fallback(0, None), (8, 16)); - assert_eq!(cell_size_fallback(0, Some((11, 22))), (11, 22)); - assert_eq!( - cell_size_fallback(pack_cell_size(10, 21), Some((11, 22))), - (10, 21) - ); - assert_eq!(cell_size_fallback(pack_cell_size(10, 0), None), (8, 16)); - assert_eq!(cell_size_fallback(pack_cell_size(0, 21), None), (8, 16)); - } - - #[test] - fn reported_cell_size_is_taken_from_host_cell_size_events() { - let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); - assert_eq!(reported_cell_size_from_events(&events), None); - - let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[6;21;10t\x1b[6;18;9t"); - assert_eq!(reported_cell_size_from_events(&events), Some((9, 18))); - } - - #[test] - fn color_scheme_reports_are_enabled_only_for_full_clients() { - assert_eq!( - should_enable_host_color_scheme_reports(true), - !cfg!(windows) - ); - assert!(!should_enable_host_color_scheme_reports(false)); - } - - #[test] - fn terminal_restore_postlude_restores_visible_default_cursor() { - let mut output = Vec::new(); - write_terminal_restore_postlude(&mut output, false).unwrap(); - assert_eq!(output, b"\x1b[?25h\x1b[0 q"); - } - - #[test] - fn terminal_restore_postlude_disables_color_scheme_reports_when_enabled() { - let mut output = Vec::new(); - write_terminal_restore_postlude(&mut output, true).unwrap(); - - let mut expected = Vec::new(); - expected.extend_from_slice( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), - ); - expected.extend_from_slice(b"\x1b[?25h\x1b[0 q"); - assert_eq!(output, expected); - } - - #[cfg(unix)] - #[test] - fn attach_escape_detaches_on_prefix_q() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(vec![0x02], 24, 3), - AttachInputAction::None - )); - assert!(matches!( - escape.filter_input(vec![b'q'], 24, 3), - AttachInputAction::Detach - )); - } - - #[cfg(unix)] - #[test] - fn attach_escape_sends_literal_prefix_on_double_prefix() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(vec![0x02], 24, 3), - AttachInputAction::None - )); - match escape.filter_input(vec![0x02], 24, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]), - other => panic!("expected forwarded prefix, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_does_not_interpret_bracketed_paste_contents() { - let mut escape = AttachEscapeState::default(); - let paste = b"\x1b[200~one\x02q\ntwo\x1b[201~".to_vec(); - - match escape.filter_input(paste.clone(), 24, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, paste), - other => panic!("expected opaque paste, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_flushes_pending_prefix_before_bracketed_paste() { - let mut escape = AttachEscapeState::default(); - let paste = b"\x1b[200~one\ntwo\x1b[201~".to_vec(); - assert!(matches!( - escape.filter_input(vec![0x02], 24, 3), - AttachInputAction::None - )); - - assert!(matches!( - escape.filter_input(paste.clone(), 24, 3), - AttachInputAction::ForwardAfterPendingPrefix(bytes) if bytes == paste - )); - assert!(matches!( - escape.filter_input(vec![b'q'], 24, 3), - AttachInputAction::Forward(bytes) if bytes == b"q" - )); - } - - #[cfg(unix)] - #[test] - fn attach_escape_forwards_prefix_before_non_escape_key() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(vec![b'a', 0x02], 24, 3), - AttachInputAction::Forward(bytes) if bytes == b"a" - )); - match escape.filter_input(vec![b'x'], 24, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']), - other => panic!("expected forwarded bytes, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_turns_wheel_into_scroll_action() { - let mut escape = AttachEscapeState::default(); - match escape.filter_input(b"\x1b[<64;11;6M".to_vec(), 24, 7) { - AttachInputAction::Scroll { - source, - direction, - lines, - column, - row, - .. - } => { - assert_eq!(source, AttachScrollSource::Wheel); - assert_eq!(direction, AttachScrollDirection::Up); - assert_eq!(lines, 7); - assert_eq!(column, Some(10)); - assert_eq!(row, Some(5)); - } - other => panic!("expected scroll action, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_swallows_non_wheel_mouse_reports() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), - AttachInputAction::None - )); - } - - #[cfg(unix)] - #[test] - fn attach_escape_turns_plain_page_keys_into_scroll_actions() { - let mut escape = AttachEscapeState::default(); - match escape.filter_input(b"\x1b[5~".to_vec(), 12, 3) { - AttachInputAction::Scroll { - source, - direction, - lines, - .. - } => { - assert_eq!( - source, - AttachScrollSource::PageKey { - input: b"\x1b[5~".to_vec() - } - ); - assert_eq!(direction, AttachScrollDirection::Up); - assert_eq!(lines, 11); - } - other => panic!("expected page-up scroll action, got {other:?}"), - } - - match escape.filter_input(b"\x1b[6~".to_vec(), 12, 3) { - AttachInputAction::Scroll { - source, - direction, - lines, - .. - } => { - assert_eq!( - source, - AttachScrollSource::PageKey { - input: b"\x1b[6~".to_vec() - } - ); - assert_eq!(direction, AttachScrollDirection::Down); - assert_eq!(lines, 11); - } - other => panic!("expected page-down scroll action, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_forwards_modified_page_key() { - let mut escape = AttachEscapeState::default(); - match escape.filter_input(b"\x1b[5;5~".to_vec(), 12, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[5;5~"), - other => panic!("expected modified page key to forward, got {other:?}"), - } - } - - #[test] - fn client_error_display_connection_failed() { - let err = ClientError::ConnectionFailed(io::Error::new( - io::ErrorKind::ConnectionRefused, - "connection refused", - )); - let msg = err.to_string(); - assert!( - msg.contains("failed to connect to server"), - "should mention connection failure: {msg}" - ); - assert!( - msg.contains("herdr server"), - "should suggest starting server: {msg}" - ); - } - - #[test] - fn client_error_display_handshake_rejected() { - let err = ClientError::HandshakeRejected { - version: 1, - error: "incompatible".into(), - }; - let msg = err.to_string(); - assert!( - msg.contains("rejected handshake"), - "should mention rejection: {msg}" - ); - assert!(msg.contains("incompatible"), "should include error: {msg}"); - } - - #[test] - fn client_error_display_server_shutdown() { - let err = ClientError::ServerShutdown { - reason: Some("maintenance".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("server shut down"), - "should mention shutdown: {msg}" - ); - assert!(msg.contains("maintenance"), "should include reason: {msg}"); - } - - #[test] - fn client_error_display_server_shutdown_no_reason() { - let err = ClientError::ServerShutdown { reason: None }; - let msg = err.to_string(); - assert!( - msg.contains("server shut down"), - "should mention shutdown: {msg}" - ); - } - - #[test] - fn client_error_display_detached_default_session_reattach_hint() { - let _guard = env_lock().lock().unwrap(); - let _env = EnvVarsRemovedGuard::new(&[ - crate::remote::REATTACH_COMMAND_ENV_VAR, - crate::session::SESSION_ENV_VAR, - ]); - let err = ClientError::ServerShutdown { - reason: Some("detached".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("Run `herdr` to reattach"), - "should suggest default reattach command: {msg}" - ); - } - - #[test] - fn client_error_display_detached_named_session_reattach_hint() { - let _guard = env_lock().lock().unwrap(); - let _remote_env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); - let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); - let err = ClientError::ServerShutdown { - reason: Some("detached".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("Run `herdr session attach work` to reattach"), - "should suggest named session reattach command: {msg}" - ); - } - - #[test] - fn client_error_display_detached_remote_reattach_hint_takes_precedence() { - let _guard = env_lock().lock().unwrap(); - let _remote_env = EnvVarGuard::set( - crate::remote::REATTACH_COMMAND_ENV_VAR, - "herdr --remote host --session work", - ); - let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); - let err = ClientError::ServerShutdown { - reason: Some("detached".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("Run `herdr --remote host --session work` to reattach"), - "should prefer remote reattach command: {msg}" - ); - } - - #[test] - fn client_error_display_connection_lost() { - let _guard = env_lock().lock().unwrap(); - let _env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); - let err = - ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); - let msg = err.to_string(); - assert!( - msg.contains("lost connection to server"), - "should mention lost connection: {msg}" - ); - } - - #[test] - fn client_error_display_remote_connection_lost_has_reattach_hint() { - let _guard = env_lock().lock().unwrap(); - let _remote_env = EnvVarGuard::set( - crate::remote::REATTACH_COMMAND_ENV_VAR, - "herdr --remote host --session work", - ); - let err = - ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); - let msg = err.to_string(); - assert!( - msg.contains("lost connection to remote Herdr"), - "should mention remote connection loss: {msg}" - ); - assert!( - msg.contains("panes may still be running"), - "should explain possible persistence: {msg}" - ); - assert!( - msg.contains("Run `herdr --remote host --session work` to reattach"), - "should show remote reattach command: {msg}" - ); - } - - #[test] - fn sound_from_notify_message_maps_done() { - assert_eq!( - sound_from_notify_message("agent done"), - Some(crate::sound::Sound::Done) - ); - } - - #[test] - fn sound_from_notify_message_maps_attention() { - assert_eq!( - sound_from_notify_message("agent attention"), - Some(crate::sound::Sound::Request) - ); - } - - #[test] - fn sound_from_notify_message_rejects_unknown_payloads() { - assert_eq!(sound_from_notify_message("toast"), None); - } - - #[test] - fn reload_local_client_config_refreshes_local_client_presentation_state() { - let _guard = crate::config::test_config_env_lock().lock().unwrap(); - let path = std::env::temp_dir().join(format!( - "herdr-client-config-reload-{}-{}.toml", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::write( - &path, - "[ui]\nredraw_on_focus_gained = false\nhost_cursor = \"drawn\"\n", - ) - .unwrap(); - let path_string = path.to_string_lossy().to_string(); - let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string); - let mut sound_config = crate::config::SoundConfig::default(); - let mut redraw_on_focus_gained = true; - let mut draw_host_cursor = false; - let mut remote_image_paste_key = None; - - reload_local_client_config( - &mut sound_config, - &mut redraw_on_focus_gained, - &mut draw_host_cursor, - &mut remote_image_paste_key, - ); - - assert!(!redraw_on_focus_gained); - assert!(draw_host_cursor); - let _ = std::fs::remove_file(path); - } - - #[test] - fn toast_notify_from_server_is_emitted_even_when_attach_config_was_off() { - let sound_config = crate::config::SoundConfig::default(); - let mut emitted = None; - - handle_notify_with_notifiers( - NotifyKind::Toast, - "pi finished", - Some("workspace 1"), - &sound_config, - |title, body| { - emitted = Some((title.to_string(), body.map(str::to_string))); - Ok(true) - }, - |_, _| Ok(false), - ); - - assert_eq!( - emitted, - Some(("pi finished".to_string(), Some("workspace 1".to_string()))) - ); - } - - #[test] - fn system_toast_notify_from_server_uses_system_notifier() { - let sound_config = crate::config::SoundConfig::default(); - let mut emitted = None; - - handle_notify_with_notifiers( - NotifyKind::SystemToast, - "pi finished", - Some("workspace 1"), - &sound_config, - |_, _| Ok(false), - |title, body| { - emitted = Some((title.to_string(), body.map(str::to_string))); - Ok(true) - }, - ); - - assert_eq!( - emitted, - Some(("pi finished".to_string(), Some("workspace 1".to_string()))) - ); - } - - #[test] - fn system_toast_notify_preserves_colon_in_title() { - let sound_config = crate::config::SoundConfig::default(); - let mut emitted = None; - - handle_notify_with_notifiers( - NotifyKind::SystemToast, - "build: failed", - Some("api workspace"), - &sound_config, - |_, _| Ok(false), - |title, body| { - emitted = Some((title.to_string(), body.map(str::to_string))); - Ok(true) - }, - ); - - assert_eq!( - emitted, - Some(( - "build: failed".to_string(), - Some("api workspace".to_string()) - )) - ); - } - - #[test] - fn decode_clipboard_payload_decodes_base64() { - assert_eq!(decode_clipboard_payload("dGVzdA=="), Some(b"test".to_vec())); - } - - #[test] - fn decode_clipboard_payload_rejects_invalid_base64() { - assert_eq!(decode_clipboard_payload("not-base64!!!"), None); - } - - #[test] - fn terminal_control_input_command_accepts_text() { - let action = - terminal_control_command_from_json(r#"{"type":"terminal.input","text":"hello"}"#) - .unwrap(); - let ClientMessage::Input { data } = action else { - panic!("expected input command"); - }; - assert_eq!(data, b"hello"); - } - - #[test] - fn terminal_control_input_command_accepts_base64_bytes() { - let action = - terminal_control_command_from_json(r#"{"type":"terminal.input","bytes":"G1tB"}"#) - .unwrap(); - let ClientMessage::Input { data } = action else { - panic!("expected input command"); - }; - assert_eq!(data, b"\x1b[A"); - } - - #[test] - fn terminal_control_resize_command_maps_to_client_resize() { - let action = terminal_control_command_from_json( - r#"{"type":"terminal.resize","cols":100,"rows":30,"cell_width_px":8,"cell_height_px":16}"#, - ) - .unwrap(); - let ClientMessage::Resize { - cols, - rows, - cell_width_px, - cell_height_px, - } = action - else { - panic!("expected resize command"); - }; - assert_eq!( - (cols, rows, cell_width_px, cell_height_px), - (100, 30, 8, 16) - ); - } - - #[test] - fn terminal_control_scroll_command_maps_to_attach_scroll() { - let action = terminal_control_command_from_json( - r#"{"type":"terminal.scroll","direction":"up","lines":3}"#, - ) - .unwrap(); - let ClientMessage::AttachScroll { - source, - direction, - lines, - .. - } = action - else { - panic!("expected scroll command"); - }; - assert_eq!(source, AttachScrollSource::Wheel); - assert_eq!(direction, AttachScrollDirection::Up); - assert_eq!(lines, 3); - } - - #[test] - fn forward_clipboard_uses_local_clipboard_path() { - unsafe { - std::env::set_var("SSH_CONNECTION", "1 2 3 4"); - } - forward_clipboard("dGVzdA=="); - unsafe { - std::env::remove_var("SSH_CONNECTION"); - } - } -} +mod tests; diff --git a/src/client/notifications.rs b/src/client/notifications.rs new file mode 100644 index 00000000..7c97742e --- /dev/null +++ b/src/client/notifications.rs @@ -0,0 +1,102 @@ +use std::io; + +use tracing::{debug, warn}; + +use crate::protocol::NotifyKind; + +use super::shell; + +pub(super) fn handle_shell_notification_effects( + effects: Vec, + sound_config: &crate::config::SoundConfig, +) { + for effect in effects { + match effect { + shell::ClientShellNotificationEffect::Sound { sound, agent } => { + let agent = agent.as_deref().and_then(crate::detect::parse_agent_label); + if sound_config.allows(agent) { + crate::sound::play(sound, sound_config); + } + } + shell::ClientShellNotificationEffect::Terminal { title, body } => { + if let Err(err) = crate::terminal_notify::show_notification(&title, body.as_deref()) + { + warn!(err = %err, "failed to emit terminal notification"); + } + } + shell::ClientShellNotificationEffect::System { title, body } => { + if let Err(err) = + crate::platform::show_desktop_notification(&title, body.as_deref()) + { + warn!(err = %err, "failed to emit system notification"); + } + } + } + } +} + +pub(super) fn handle_notify( + kind: NotifyKind, + message: &str, + body: Option<&str>, + sound_config: &crate::config::SoundConfig, +) { + handle_notify_with_notifiers( + kind, + message, + body, + sound_config, + crate::terminal_notify::show_notification, + crate::platform::show_desktop_notification, + ); +} + +pub(super) fn handle_notify_with_notifiers( + kind: NotifyKind, + message: &str, + body: Option<&str>, + sound_config: &crate::config::SoundConfig, + mut show_terminal_notification: impl FnMut(&str, Option<&str>) -> io::Result, + mut show_system_notification: impl FnMut(&str, Option<&str>) -> io::Result, +) { + match kind { + NotifyKind::Sound => { + let Some(sound) = sound_from_notify_message(message) else { + warn!( + message = message, + "received unknown sound notification from server" + ); + return; + }; + if sound_config.enabled { + crate::sound::play(sound, sound_config); + } + } + NotifyKind::Toast => { + debug!( + message = message, + "received terminal toast notification from server" + ); + if let Err(err) = show_terminal_notification(message, body) { + warn!(err = %err, "failed to emit terminal notification"); + } + } + NotifyKind::SystemToast => { + debug!( + message = message, + "received system toast notification from server" + ); + if let Err(err) = show_system_notification(message, body) { + warn!(err = %err, "failed to emit system notification"); + } + } + } +} + +pub(super) fn sound_from_notify_message(message: &str) -> Option { + match message { + "agent done" => Some(crate::sound::Sound::Done), + "agent attention" => Some(crate::sound::Sound::Request), + _ => None, + } +} diff --git a/src/client/shell.rs b/src/client/shell.rs index dff97d70..261e041b 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -67,6 +67,19 @@ fn delete_overlay_word(rename: &mut ClientRenameOverlay) { } } +fn target_event_message(target: ClientInputTarget, event: ClientPaneInputEvent) -> ClientMessage { + match target { + ClientInputTarget::Pane(pane_id) => ClientMessage::ClientShellPaneInput { + pane_id, + events: vec![event], + }, + ClientInputTarget::Popup(terminal_id) => ClientMessage::ClientShellPopupInput { + terminal_id, + events: vec![event], + }, + } +} + fn push_target_event( target: ClientInputTarget, event: ClientPaneInputEvent, @@ -84,10 +97,10 @@ fn push_target_event( return; } } - outcome.requests.push(ClientMessage::ClientShellPaneInput { - pane_id, - events: vec![event], - }); + outcome.requests.push(target_event_message( + ClientInputTarget::Pane(pane_id), + event, + )); } ClientInputTarget::Popup(terminal_id) => { if let Some(ClientMessage::ClientShellPopupInput { @@ -100,10 +113,10 @@ fn push_target_event( return; } } - outcome.requests.push(ClientMessage::ClientShellPopupInput { - terminal_id, - events: vec![event], - }); + outcome.requests.push(target_event_message( + ClientInputTarget::Popup(terminal_id), + event, + )); } } } @@ -267,6919 +280,4 @@ fn blit_pane_surface(target: &mut FrameData, source: &FrameData, area: Rect) { } #[cfg(test)] -mod tests { - use super::*; - use crate::api::schema::AgentStatus; - use crate::protocol::{ - ClientShellAgent, ClientShellPane, ClientShellTab, ClientShellWorktree, PaneSurfacePane, - PaneSurfaceSplit, PaneSurfaceSplitDirection, SurfaceRect, - }; - - 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, - 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, - 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, - 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, - current: Option, - ) -> 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 - } - - #[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::() - }) - .collect::>() - .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_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 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::>() - .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::() - }) - .collect::>() - .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::>() - .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::() - }) - .collect::>() - .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 _guard = crate::config::test_config_env_lock().lock().unwrap(); - 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() - )); - let original_config_path = std::env::var_os(crate::config::CONFIG_PATH_ENV_VAR); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - std::fs::write(&path, "[terminal]\ndefault_shell = \"fish\"\n") - .expect("write onboarding config"); - - let config = - ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); - 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 = - ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); - 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 = - ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); - 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"); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &unreadable_path); - let config = - ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); - 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"); - - if let Some(original) = original_config_path { - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, original); - } else { - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - } - 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::() - }) - .collect::>() - .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 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::() - }) - .collect::>() - .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 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 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 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_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 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 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_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 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 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()); - } - - #[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 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 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, .. }, - .. - }] - ) - )); - } - - #[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 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 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 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 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.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); - std::fs::remove_file(path).expect("remove client chrome preferences"); - } - - #[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 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 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 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::(); - 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::(); - assert!(!top.contains("ZOOM · host")); - } - - #[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 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()); - } - - #[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::() - }) - .collect::>() - .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.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) - ); - } - - #[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 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 configured_prefix_is_client_owned_and_renders_its_bar() { - let config = toml::from_str::( - 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::() - }) - .collect::>() - .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 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::() - }) - .collect::>() - .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::() - }) - .collect::>() - .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 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::() - }) - .collect::>() - .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 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::>() - .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::(); - 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::(); - 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::() - }) - .collect::>() - .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::(); - 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::() - }) - .collect::>() - .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::(); - assert!(installed_text.contains("what's new")); - assert!(!installed_text.contains("● what's new")); - } - - #[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::() - }; - 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::(); - assert!(mobile_text.contains("update ready")); - } - - #[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::>() - .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::>() - .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::(); - 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::(); - 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::(); - 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::(); - 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::(); - 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::(); - 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::() - }) - .collect::>() - .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"] - )); - } - - #[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!(!serde_json::to_string(request) - .unwrap() - .contains("secret-command")); - } - - #[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::() - }) - .collect::>() - .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::() - }) - .collect::>() - .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::() - }) - .collect::>() - .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 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::() - }) - .collect::>() - .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); - 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, - }, - ]; - projected.agent_view_label = Some("review".into()); - projected.agent_order = vec!["pane_2".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!["pane_2"] - ); - 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" - ) - )); - } - - #[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 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 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::() - }) - .collect::>() - .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 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 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::() - }) - .collect::>() - .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 - )); - } - - #[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::() - }) - .collect::>() - .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 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); - } - - #[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::() - }) - .collect::>() - .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 worktree_create_prepares_from_public_list_and_submits_derived_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::() - }) - .collect::>() - .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()); - 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.as_deref().is_some_and(|path| path.ends_with("repo/feature-client-shell")) - && 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::() - }) - .collect::>() - .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::() - }) - .collect::>() - .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::() - }) - .collect::>() - .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); - } - - #[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 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"); - } - - #[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 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 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)); - } - - #[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::(); - 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::() - }) - .collect::>() - .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::() - }) - .collect::>() - .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()); - } -} +mod tests; diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 3191aa9d..efc9aae9 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -186,11 +186,43 @@ impl ClientShellState { 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; @@ -211,6 +243,14 @@ impl ClientShellState { } 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, + ) { let Some(selection) = self.selection.as_ref() else { return; }; @@ -221,6 +261,27 @@ impl ClientShellState { .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 { @@ -236,7 +297,7 @@ impl ClientShellState { content_revision, }, ), - PendingEndpointKind::SelectionCopy, + PendingEndpointKind::SelectionCopy { fallback }, outcome, ); } @@ -431,34 +492,34 @@ impl ClientShellState { let repaint = self.complete_pane_scroll(pane_id, serial, result, &mut outcome); return (repaint, outcome.actions); } - PendingEndpointKind::SelectionCopy => { + PendingEndpointKind::SelectionCopy { fallback } => { + let fallback = || { + fallback + .map(ClientShellAction::Request) + .into_iter() + .collect::>() + }; return match result { Ok(crate::api::schema::ResponseResult::PaneSelection { text, .. }) if !text.is_empty() => { - if self.config.clipboard_toast_enabled { - self.copy_feedback = Some(crate::app::state::CopyFeedback { - message: "copied to clipboard".to_owned(), - }); - self.copy_feedback_deadline = - Some(std::time::Instant::now() + std::time::Duration::from_secs(2)); - } + let repaint = self.show_copy_feedback(std::time::Instant::now()); ( - self.config.clipboard_toast_enabled, + repaint, vec![ClientShellAction::ClipboardWrite(text.into_bytes())], ) } Ok(crate::api::schema::ResponseResult::PaneSelection { .. }) => { - (false, Vec::new()) + (false, fallback()) } Ok(_) => { self.endpoint_error = Some("endpoint returned an unexpected selection result".to_owned()); - (true, Vec::new()) + (true, fallback()) } Err(error) => { self.endpoint_error = Some(error.message); - (true, Vec::new()) + (true, fallback()) } }; } @@ -521,6 +582,70 @@ impl ClientShellState { 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>| { + 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, @@ -662,7 +787,7 @@ impl ClientShellState { } pub(super) fn endpoint_method_for_action( - &self, + &mut self, action: crate::input::KeybindAction, ) -> Option { use crate::api::schema::{ @@ -710,47 +835,61 @@ impl ClientShellState { if agents.is_empty() { return None; } - let current = agents - .iter() - .position(|pane_id| { - Some(pane_id.as_str()) == snapshot.focused_pane_id.as_deref() - }) - .unwrap_or(0); - let next = if action == KeybindAction::PreviousAgent { - (current + agents.len() - 1) % agents.len() - } else { - (current + 1) % agents.len() + 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"), }; - Some(Method::PaneFocus(PaneTarget { - pane_id: agents[next].clone(), - })) + 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); - Some(Method::WorkspaceFocus(WorkspaceTarget { - workspace_id: snapshot - .workspaces - .get(entries.get(index)?.index)? - .workspace_id - .clone(), - })) + 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); - let current = entries.iter().position(|entry| { - snapshot.workspaces[entry.index].workspace_id == focused_workspace - })?; + 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; - Some(Method::WorkspaceFocus(WorkspaceTarget { - workspace_id: snapshot.workspaces[entries[next].index] - .workspace_id - .clone(), - })) + 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 diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs index 5c35d24d..0273cefb 100644 --- a/src/client/shell/config.rs +++ b/src/client/shell/config.rs @@ -30,6 +30,8 @@ impl ClientShellState { let Some(path) = self.config.preferences_path.as_deref() else { return; }; + let mut collapsed_groups = self.collapsed_groups.iter().cloned().collect::>(); + collapsed_groups.sort(); let preferences = preferences::ClientChromePreferences { sidebar_width: self.sidebar_width_manual.then_some(self.sidebar_width), sidebar_section_split: self @@ -41,6 +43,7 @@ impl ClientShellState { 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); @@ -57,6 +60,12 @@ impl ClientShellState { &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; } @@ -79,6 +88,7 @@ impl ClientShellState { self.set_local_config_diagnostic(self.config.local_config_diagnostic(&diagnostics)); } } + self.reconcile_input_source(); } } @@ -123,9 +133,11 @@ impl ClientShellConfig { mouse_capture: config.ui.mouse_capture, mouse_scroll_lines: config.ui.mouse_scroll_lines(), right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(), - worktree_directory: crate::worktree::expand_tilde_absolute_path( - &config.worktrees.directory, - ), + 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, @@ -304,6 +316,7 @@ impl ClientShellConfig { 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; } } @@ -312,9 +325,9 @@ impl ClientShellConfig { self.theme_name = self.theme_runtime.manual_name.clone(); self.palette = crate::app::client_palette_from_config(config); } - if !invalid_section("worktrees") { - self.worktree_directory = - crate::worktree::expand_tilde_absolute_path(&config.worktrees.directory); + if !invalid_section("experimental") { + self.switch_ascii_input_source_in_prefix = + config.experimental.switch_ascii_input_source_in_prefix; } diagnostics @@ -423,7 +436,6 @@ mod tests { next.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; next.ui.sidebar.agents.row_gap = 2; next.keys.prefix = "ctrl+a".to_owned(); - next.worktrees.directory = "/var/tmp/herdr-reloaded-worktrees".to_owned(); let diagnostics = shell.apply_live_config(&next, &[], &[]); @@ -443,10 +455,6 @@ mod tests { shell.keybinds.prefix, (KeyCode::Char('a'), KeyModifiers::CONTROL) ); - assert_eq!( - shell.worktree_directory, - std::path::PathBuf::from("/var/tmp/herdr-reloaded-worktrees") - ); } #[test] @@ -478,14 +486,12 @@ mod tests { let mut initial = Config::default(); initial.ui.sidebar_width = 29; initial.keys.prefix = "ctrl+x".to_owned(); - initial.worktrees.directory = "/var/tmp/herdr-current-worktrees".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(); - invalid.worktrees.directory = "/var/tmp/herdr-invalid-worktrees".to_owned(); - let invalid_sections = vec!["ui".to_owned(), "keys".to_owned(), "worktrees".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); @@ -493,9 +499,5 @@ mod tests { shell.keybinds.prefix, (KeyCode::Char('x'), KeyModifiers::CONTROL) ); - assert_eq!( - shell.worktree_directory, - std::path::PathBuf::from("/var/tmp/herdr-current-worktrees") - ); } } diff --git a/src/client/shell/context_menu.rs b/src/client/shell/context_menu.rs index 142e68e0..af271bb6 100644 --- a/src/client/shell/context_menu.rs +++ b/src/client/shell/context_menu.rs @@ -282,6 +282,7 @@ impl ClientShellState { if !self.collapsed_groups.remove(&key) { self.collapsed_groups.insert(key); } + self.persist_chrome_preferences(outcome); } } _ => {} diff --git a/src/client/shell/copy_mode.rs b/src/client/shell/copy_mode.rs index 2c8b703d..c39a7507 100644 --- a/src/client/shell/copy_mode.rs +++ b/src/client/shell/copy_mode.rs @@ -196,8 +196,7 @@ impl ClientShellState { _ => {} } - let Some(command) = crate::app::input::copy_mode::copy_mode_command_char(key.clone()) - else { + let Some(command) = crate::copy_mode::copy_mode_command_char(key.clone()) else { return; }; match command { @@ -304,8 +303,7 @@ impl ClientShellState { } } _ => { - if let Some(ch) = crate::app::input::copy_mode::copy_mode_command_char(key.clone()) - { + if let Some(ch) = crate::copy_mode::copy_mode_command_char(key.clone()) { if let Some(prompt) = self .copy_mode .as_mut() @@ -331,7 +329,9 @@ impl ClientShellState { else { return false; }; - prompt.query.push_str(text); + prompt + .query + .extend(text.chars().filter(|character| !character.is_control())); true } @@ -560,8 +560,7 @@ impl ClientShellState { let Some(hit) = self.copy_hit() else { return; }; - let lines = - crate::app::input::copy_mode::copy_mode_page_lines(hit.inner_rect.height, half_page); + 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); diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index d6d6fecb..d3af1480 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -3,12 +3,69 @@ 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 { + 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)) @@ -67,12 +124,28 @@ impl ClientShellState { false } + pub(crate) fn replay_mouse_events( + &mut self, + events: Vec, + ) -> 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) -> 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) => { @@ -85,9 +158,11 @@ impl ClientShellState { | 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() { @@ -116,9 +191,11 @@ impl ClientShellState { | 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() { @@ -142,9 +219,36 @@ impl ClientShellState { 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::OuterFocusLost => self.outer_focused = Some(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, @@ -158,6 +262,7 @@ impl ClientShellState { | RawInputEvent::HostCellSizeReport { .. } | RawInputEvent::Unsupported => {} } + self.reconcile_input_source(); } outcome.repaint |= self.resume_mobile_switcher_if_ready(); outcome @@ -172,7 +277,6 @@ impl ClientShellState { self.copy_input_queue.push_back(key); return; } - const LOCAL_INPUT_SOURCE: u8 = 0; let lease_key = crate::input::InputLeaseKey::new(LOCAL_INPUT_SOURCE, &key); let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { @@ -201,7 +305,11 @@ impl ClientShellState { } KeyEventKind::Release => { if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { - self.push_pane_key(lease.target, key, outcome); + 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); } @@ -209,6 +317,51 @@ impl ClientShellState { } } + 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, @@ -252,11 +405,69 @@ impl ClientShellState { } } + 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, + ) -> 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 { + if self.handle_modal_paste_shortcut_with(key, outcome, crate::platform::read_clipboard_text) + { + return None; + } if matches!( self.overlay, Some( @@ -293,7 +504,7 @@ impl ClientShellState { .as_ref() .is_some_and(crate::selection::Selection::is_visible) { - self.request_selection_copy(outcome); + self.request_selection_copy_with_fallback(outcome, Some(key.clone())); self.selection = None; self.stop_selection_autoscroll(); self.selection_highlight_clear_deadline = None; @@ -649,12 +860,14 @@ impl ClientShellState { } else { (current as isize + delta).rem_euclid(entries.len() as isize) as usize }; - self.navigate_workspace_id = Some( - snapshot.workspaces[entries[next].index] - .workspace_id - .clone(), - ); + 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) { @@ -741,6 +954,38 @@ impl ClientShellState { .and_then(|snapshot| snapshot.focused_pane_id.clone()) } + pub(crate) fn clipboard_image_target( + &self, + ) -> Option { + 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 { self.popup_terminal_id .as_ref() diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index c9c1bff4..437ec4ed 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -1,6 +1,8 @@ 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( @@ -249,7 +251,7 @@ impl ClientShellState { max_offset_from_bottom: metrics.max_offset_from_bottom, }); self.selection_autoscroll_deadline = - Some(std::time::Instant::now() + crate::app::SELECTION_AUTOSCROLL_INTERVAL); + Some(std::time::Instant::now() + SELECTION_AUTOSCROLL_INTERVAL); } fn scroll_in_progress_selection( @@ -368,7 +370,7 @@ impl ClientShellState { ); self.push_pane_scroll_offset(autoscroll.pane_id.clone(), next_offset, &mut outcome); self.selection_autoscroll = Some(autoscroll); - self.selection_autoscroll_deadline = Some(now + crate::app::SELECTION_AUTOSCROLL_INTERVAL); + self.selection_autoscroll_deadline = Some(now + SELECTION_AUTOSCROLL_INTERVAL); outcome.repaint = true; outcome } @@ -726,6 +728,44 @@ impl ClientShellState { } 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, @@ -749,6 +789,11 @@ impl ClientShellState { .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; @@ -772,9 +817,11 @@ impl ClientShellState { 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, }); } } @@ -795,6 +842,57 @@ impl ClientShellState { 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 @@ -1625,9 +1723,11 @@ impl ClientShellState { 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; } @@ -1899,6 +1999,7 @@ impl ClientShellState { self.collapsed_groups.insert(key.clone()); } outcome.repaint = true; + self.persist_chrome_preferences(outcome); return; } } @@ -2035,9 +2136,11 @@ impl ClientShellState { 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 { @@ -2087,9 +2190,11 @@ impl ClientShellState { { 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, }); } } @@ -2132,21 +2237,12 @@ impl ClientShellState { } } - 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; - }; + 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), }; - let position = if hit.sgr_pixel_mouse && hit.pixel_width > 0 && hit.pixel_height > 0 { + if hit.sgr_pixel_mouse && hit.pixel_width > 0 && hit.pixel_height > 0 { self.host_mouse_pixels .and_then(|pixels| { pixels @@ -2166,7 +2262,28 @@ impl ClientShellState { .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 { @@ -2177,6 +2294,7 @@ impl ClientShellState { ClientPaneInputEvent::Mouse { kind, position, + geometry, modifiers: modifiers.bits(), lines: self.config.mouse_scroll_lines.min(u16::MAX as usize) as u16, }, diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index 840d1372..154b00b6 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -178,9 +178,11 @@ impl ClientShellState { if self.snapshot.is_none() { return; } - if let Err(error) = crate::config::update_file("onboarding setting", |content| { - crate::config::upsert_top_level_bool(content, "onboarding", false) - }) { + 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; @@ -423,7 +425,8 @@ impl ClientShellState { true } Some(ClientShellOverlay::Help(help)) if help.search_focused => { - help.query.push_str(text); + help.query + .extend(text.chars().filter(|character| !character.is_control())); help.scroll = 0; true } diff --git a/src/client/shell/preferences.rs b/src/client/shell/preferences.rs index 3e83bdac..dc6cc1ac 100644 --- a/src/client/shell/preferences.rs +++ b/src/client/shell/preferences.rs @@ -7,7 +7,7 @@ static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1); use serde::{Deserialize, Serialize}; -#[derive(Clone, Copy, Debug, Default, 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, @@ -17,6 +17,8 @@ pub(super) struct ClientChromePreferences { pub(super) sidebar_collapsed: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) agent_panel_sort: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(super) collapsed_groups: Vec, } pub(super) fn path_for_local_endpoint(socket_path: &Path) -> PathBuf { diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index d9c6951d..6e477706 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -43,7 +43,9 @@ pub(crate) struct ClientShellConfig { pub(super) mouse_capture: bool, pub(super) mouse_scroll_lines: usize, pub(super) right_click_passthrough_modifiers: Option, - pub(super) worktree_directory: std::path::PathBuf, + 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, pub(super) preferences: preferences::ClientChromePreferences, pub(super) startup_config_diagnostic: Option, @@ -151,6 +153,8 @@ 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 { @@ -222,6 +226,9 @@ pub(crate) enum ClientShellAction { request: Box, }, ClipboardWrite(Vec), + Request(ClientMessage), + OpenSafeWebUrl(String), + ReplayMouse(Vec), Keybind(crate::input::KeybindAction), } @@ -231,6 +238,7 @@ pub(crate) struct ClientShellInput { pub repaint: bool, pub resize: bool, pub query_host_appearance: bool, + pub query_host_theme: bool, pub requests: Vec, pub actions: Vec, } @@ -592,7 +600,9 @@ pub(super) enum PendingEndpointKind { WorktreeRemove { forced: bool, }, - SelectionCopy, + SelectionCopy { + fallback: Option, + }, PaneScroll { pane_id: String, serial: u64, @@ -603,6 +613,11 @@ pub(super) enum PendingEndpointKind { col: u16, generation: u64, }, + PaneLinkActivate { + pane_id: String, + inner_rect: Rect, + fallback_events: Vec, + }, CopyMotion { pane_id: String, origin: crate::api::schema::PaneTextPoint, @@ -796,6 +811,8 @@ pub(crate) struct ClientShellState { pub(super) overlay: Option, pub(super) previous_pane_id: Option, pub(super) pane_mouse_gesture: Option, + pub(super) url_click_consumes_until_up: bool, + pub(super) replaying_url_click: bool, pub(super) selection: Option>, pub(super) last_pane_click: Option, pub(super) selection_autoscroll: Option, @@ -824,6 +841,10 @@ pub(crate) struct ClientShellState { pub(super) pending_notifications: Vec, pub(super) visible_notification: Option, pub(super) outer_focused: Option, + pub(super) ascii_input_source_active: bool, + pub(super) pending_input_source_changes: Vec, + pub(super) host_appearance: Option, + pub(super) host_appearance_explicit: bool, pub(super) local_config_diagnostic: Option, pub(super) config_diagnostic: Option, pub(super) endpoint_error: Option, @@ -863,7 +884,7 @@ pub(super) struct WorkspaceEntry { impl ClientShellState { pub(crate) fn new(mut config: ClientShellConfig) -> Self { - let preferences = config.preferences; + let preferences = config.preferences.clone(); let local_config_diagnostic = config.startup_config_diagnostic.take(); let overlay = config .startup_onboarding @@ -909,7 +930,7 @@ impl ClientShellState { chrome_drag: None, workspace_press: None, tab_press: None, - collapsed_groups: HashSet::new(), + collapsed_groups: preferences.collapsed_groups.into_iter().collect(), workspace_scroll: 0, agent_scroll: 0, tab_scroll: 0, @@ -925,6 +946,8 @@ impl ClientShellState { 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, @@ -953,6 +976,10 @@ impl ClientShellState { 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, @@ -1008,6 +1035,25 @@ impl ClientShellState { } } + 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, @@ -1090,7 +1136,6 @@ impl ClientShellState { self.chrome_drag = None; self.workspace_press = None; self.tab_press = None; - self.collapsed_groups.clear(); self.workspace_scroll = 0; self.agent_scroll = 0; self.tab_scroll = 0; @@ -1117,6 +1162,8 @@ impl ClientShellState { .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; @@ -1313,6 +1360,7 @@ impl ClientShellState { } 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) { @@ -1341,6 +1389,10 @@ impl ClientShellState { .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())); @@ -1461,6 +1513,7 @@ impl ClientShellState { .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) { @@ -1473,6 +1526,17 @@ impl ClientShellState { } } + 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 @@ -1506,4 +1570,42 @@ impl ClientShellState { 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 { + std::mem::take(&mut self.pending_input_source_changes) + } } 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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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!["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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::(); + 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::(); + assert!(!top.contains("ZOOM · host")); +} + +#[test] +fn configured_prefix_is_client_owned_and_renders_its_bar() { + let config = toml::from_str::( + 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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }; + 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::(); + 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::(); + 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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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..62b36189 --- /dev/null +++ b/src/client/shell/tests/mod.rs @@ -0,0 +1,218 @@ +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, + 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, + 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, + current: Option, +) -> 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..ef832b27 --- /dev/null +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -0,0 +1,1036 @@ +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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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)); +} 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::>() + .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::() + }) + .collect::>() + .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::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::>() + .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::(); + 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::(); + 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::() + }) + .collect::>() + .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::(); + 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::() + }) + .collect::>() + .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::(); + 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::>() + .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::>() + .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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::() + }) + .collect::>() + .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/worktrees.rs b/src/client/shell/worktrees.rs index dc6cca81..792cd248 100644 --- a/src/client/shell/worktrees.rs +++ b/src/client/shell/worktrees.rs @@ -1,6 +1,12 @@ use super::*; impl ClientShellState { + fn endpoint_worktree_directory(&self) -> Option { + 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 => { @@ -229,11 +235,14 @@ impl ClientShellState { } 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( - &self.config.worktree_directory, + &worktree_directory, &create.repo_name, &create.branch, ) @@ -243,6 +252,9 @@ impl ClientShellState { } 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; }; @@ -257,24 +269,20 @@ impl ClientShellState { } create.branch = branch.clone(); create.replace_on_type = false; - create.checkout_path = crate::worktree::default_checkout_path( - &self.config.worktree_directory, - &create.repo_name, - &branch, - ) - .display() - .to_string(); + 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(); - let path = create.checkout_path.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: Some(path), + path: None, label: None, focus: true, trust_repository: false, @@ -376,8 +384,11 @@ impl ClientShellState { .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( - &self.config.worktree_directory, + &worktree_directory, &source.repo_name, &branch, ) @@ -518,9 +529,10 @@ impl ClientShellState { | PendingEndpointKind::ReloadConfig | PendingEndpointKind::IntegrationList | PendingEndpointKind::IntegrationInstall - | PendingEndpointKind::SelectionCopy + | PendingEndpointKind::SelectionCopy { .. } | PendingEndpointKind::PaneScroll { .. } | PendingEndpointKind::WordSelection { .. } + | PendingEndpointKind::PaneLinkActivate { .. } | PendingEndpointKind::CopyMotion { .. } | PendingEndpointKind::CopySearch { .. }, Err(error), diff --git a/src/client/terminal_geometry.rs b/src/client/terminal_geometry.rs new file mode 100644 index 00000000..d1f305ab --- /dev/null +++ b/src/client/terminal_geometry.rs @@ -0,0 +1,238 @@ +use std::io; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +#[cfg(any(unix, test))] +use tracing::debug; + +use super::ClientLoopEvent; + +const DEFAULT_CELL_WIDTH_PX: u32 = 8; +const DEFAULT_CELL_HEIGHT_PX: u32 = 16; + +/// Average cell size derived from a terminal ioctl pixel extent. +/// +/// The extent need not divide evenly by the grid: terminals may include padding, +/// and pixel mouse coordinates retain the raw extent for proportional mapping. +pub(super) fn ioctl_cell_size( + columns: u16, + rows: u16, + width_px: u32, + height_px: u32, +) -> Option<(u32, u32)> { + if columns == 0 || rows == 0 || width_px == 0 || height_px == 0 { + return None; + } + Some(( + (width_px / u32::from(columns)).max(1), + (height_px / u32::from(rows)).max(1), + )) +} + +fn ioctl_terminal_geometry() -> Option<(u16, u16, u32, u32)> { + let size = crossterm::terminal::window_size().ok()?; + let (cell_width_px, cell_height_px) = ioctl_cell_size( + size.columns, + size.rows, + u32::from(size.width), + u32::from(size.height), + )?; + Some((size.columns, size.rows, cell_width_px, cell_height_px)) +} + +pub(super) fn cell_size_fallback(reported: u64, last: Option<(u32, u32)>) -> (u32, u32) { + unpack_cell_size(reported) + .or(last.filter(|(width, height)| *width > 0 && *height > 0)) + .unwrap_or((DEFAULT_CELL_WIDTH_PX, DEFAULT_CELL_HEIGHT_PX)) +} + +#[cfg(any(unix, test))] +pub(super) fn pack_cell_size(width_px: u32, height_px: u32) -> u64 { + (u64::from(width_px) << 32) | u64::from(height_px) +} + +fn unpack_cell_size(packed: u64) -> Option<(u32, u32)> { + let width_px = (packed >> 32) as u32; + let height_px = (packed & u64::from(u32::MAX)) as u32; + (width_px > 0 && height_px > 0).then_some((width_px, height_px)) +} + +fn current_terminal_geometry( + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, + reported_cell_size: &AtomicU64, + last_cell_size: Option<(u32, u32)>, +) -> (u16, u16, u32, u32, bool) { + if !pixel_geometry_enabled { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + return (cols, rows, 0, 0, false); + } + if let Some((cols, rows, cell_width_px, cell_height_px)) = ioctl_terminal_geometry() { + return (cols, rows, cell_width_px, cell_height_px, true); + } + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + if !pixel_geometry_fallback { + return (cols, rows, 0, 0, false); + } + let (cell_width_px, cell_height_px) = + cell_size_fallback(reported_cell_size.load(Ordering::Acquire), last_cell_size); + (cols, rows, cell_width_px, cell_height_px, false) +} + +/// Reads terminal geometry before the handshake. Pixel input and direct graphics +/// are eligible only when one ioctl supplied a coherent exact geometry snapshot. +pub(super) fn initial_terminal_geometry( + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, +) -> (u16, u16, u32, u32, bool) { + if !pixel_geometry_enabled { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + return (cols, rows, 0, 0, false); + } + match ioctl_terminal_geometry() { + Some((cols, rows, width, height)) => (cols, rows, width, height, true), + None => { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + if pixel_geometry_fallback { + ( + cols, + rows, + DEFAULT_CELL_WIDTH_PX, + DEFAULT_CELL_HEIGHT_PX, + false, + ) + } else { + (cols, rows, 0, 0, false) + } + } + } +} + +pub(super) fn resize_report_required( + signalled: bool, + new_size: (u16, u16, u32, u32, bool), + last_size: (u16, u16, u32, u32, bool), +) -> bool { + signalled || new_size != last_size +} + +/// Watches the terminal size and sends resize events when it changes. +/// +/// The baseline cell size must match what the handshake sent to the server: +/// reading a fresh one here would race the host cell size reply and could +/// swallow the first change. +#[allow(clippy::too_many_arguments)] // The arguments are one immutable launch snapshot, not shared state. +pub(super) fn resize_poll_loop( + resize_tx: tokio::sync::mpsc::Sender, + initial_cols: u16, + initial_rows: u16, + initial_cell_width: u32, + initial_cell_height: u32, + initial_pixel_geometry_exact: bool, + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, + reported_cell_size: &AtomicU64, + should_quit: &Arc, +) { + crate::platform::watch_terminal_resize_signal(); + let mut last_size = ( + initial_cols, + initial_rows, + initial_cell_width, + initial_cell_height, + initial_pixel_geometry_exact, + ); + while !should_quit.load(Ordering::Acquire) { + std::thread::sleep(Duration::from_millis(100)); + let signalled = crate::platform::take_terminal_resize_signal(); + let new_size = current_terminal_geometry( + pixel_geometry_enabled, + pixel_geometry_fallback, + reported_cell_size, + Some((last_size.2, last_size.3)), + ); + if resize_report_required(signalled, new_size, last_size) { + last_size = new_size; + if resize_tx + .blocking_send(ClientLoopEvent::Resize( + new_size.0, new_size.1, new_size.2, new_size.3, new_size.4, + )) + .is_err() + { + break; + } + } + } +} + +#[cfg(any(not(windows), test))] +pub(super) fn query_host_terminal_appearance() { + let _ = write_host_terminal_appearance_query(io::stdout()); +} + +#[cfg(any(not(windows), test))] +pub(super) fn write_host_terminal_appearance_query(mut writer: impl io::Write) -> io::Result<()> { + writer.write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())?; + writer.flush() +} + +pub(super) fn query_host_terminal_theme() { + let _ = write_host_terminal_theme_query(io::stdout()); +} + +pub(super) fn should_query_host_terminal_theme() -> bool { + !cfg!(windows) +} + +pub(super) fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> { + let query = crate::terminal_theme::host_terminal_theme_query_sequence( + crate::platform::should_query_host_terminal_palette(), + ); + writer.write_all(query.as_bytes())?; + writer.flush() +} + +const HOST_CELL_SIZE_QUERY: &[u8] = b"\x1b[16t"; + +pub(super) fn query_host_cell_size() { + let _ = write_host_cell_size_query(io::stdout()); +} + +pub(super) fn should_query_host_cell_size() -> bool { + !cfg!(windows) +} + +pub(super) fn host_cell_size_query_required(kitty_graphics_enabled: bool) -> bool { + kitty_graphics_enabled && should_query_host_cell_size() && ioctl_terminal_geometry().is_none() +} + +pub(super) fn write_host_cell_size_query(mut writer: impl io::Write) -> io::Result<()> { + writer.write_all(HOST_CELL_SIZE_QUERY)?; + writer.flush() +} + +#[cfg(any(unix, test))] +pub(super) fn store_reported_cell_size( + reported_cell_size: &AtomicU64, + width_px: u32, + height_px: u32, +) { + let packed = pack_cell_size(width_px, height_px); + if reported_cell_size.swap(packed, Ordering::AcqRel) != packed { + debug!(width_px, height_px, "host terminal reported cell size"); + } +} + +#[cfg(any(unix, test))] +pub(super) fn reported_cell_size_from_events( + events: &[crate::raw_input::RawInputEvent], +) -> Option<(u32, u32)> { + events.iter().rev().find_map(|event| match event { + crate::raw_input::RawInputEvent::HostCellSizeReport { + width_px, + height_px, + } => Some((*width_px, *height_px)), + _ => None, + }) +} diff --git a/src/client/terminal_sessions.rs b/src/client/terminal_sessions.rs new file mode 100644 index 00000000..7d7bcc8f --- /dev/null +++ b/src/client/terminal_sessions.rs @@ -0,0 +1,265 @@ +use std::io::{self, BufRead, Write as _}; + +use base64::Engine; +use interprocess::local_socket::traits::Stream as _; +use interprocess::TryClone as _; +use tracing::info; + +use crate::ipc::LocalStream; +use crate::protocol::{ + self, AttachScrollDirection, AttachScrollSource, ClientMessage, RenderEncoding, ServerMessage, + MAX_GRAPHICS_FRAME_SIZE, +}; +use crate::server::socket_paths::client_socket_path; + +use super::{do_handshake, init_logging, write_to_server, ClientError}; + +/// Runs a read-only terminal session observer and prints one JSON envelope per frame. +pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io::Result<()> { + let mut stream = + connect_terminal_session_stream(target.clone(), cols, rows, "observing terminal session")?; + write_to_server(&mut stream, &ClientMessage::ObserveTerminal { target })?; + write_terminal_session_output(stream) +} + +/// Runs a writable terminal session controller. +pub fn run_terminal_session_control( + target: String, + takeover: bool, + cols: u16, + rows: u16, +) -> io::Result<()> { + let mut stream = connect_terminal_session_stream( + target.clone(), + cols, + rows, + "controlling terminal session", + )?; + write_to_server( + &mut stream, + &ClientMessage::ControlTerminal { target, takeover }, + )?; + + let mut write_stream = stream.try_clone()?; + let _input_thread = std::thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let Ok(line) = line else { + break; + }; + if line.trim().is_empty() { + continue; + } + match terminal_control_command_from_json(&line) { + Ok(message) => { + let release = matches!(message, ClientMessage::Detach); + if write_to_server(&mut write_stream, &message).is_err() { + return; + } + if release { + return; + } + } + Err(err) => eprintln!("herdr: terminal session control input ignored: {err}"), + } + } + let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); + }); + + write_terminal_session_output(stream) +} + +fn connect_terminal_session_stream( + target: String, + cols: u16, + rows: u16, + log_message: &'static str, +) -> io::Result { + init_logging(); + + let socket_path = client_socket_path(); + crate::logging::startup("client"); + info!(path = %socket_path.display(), target = %target, cols, rows, "{log_message}"); + + let mut stream = match crate::ipc::connect_local_stream(&socket_path) { + Ok(stream) => stream, + Err(err) => { + eprintln!("herdr: {}", ClientError::ConnectionFailed(err)); + std::process::exit(1); + } + }; + + match do_handshake(&mut stream, cols, rows, 0, 0, false, None, false, false) { + Ok(RenderEncoding::TerminalAnsi) => {} + Ok(encoding) => { + eprintln!( + "herdr: terminal session observe negotiated unsupported encoding {encoding:?}" + ); + std::process::exit(1); + } + Err(err) => { + eprintln!("herdr: {err}"); + std::process::exit(1); + } + } + + stream.set_nonblocking(false)?; + Ok(stream) +} + +fn write_terminal_session_output(mut stream: LocalStream) -> io::Result<()> { + let mut stdout = io::stdout().lock(); + loop { + match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) { + Ok(ServerMessage::Terminal(frame)) => { + let encoded = base64::engine::general_purpose::STANDARD.encode(&frame.bytes); + let line = serde_json::json!({ + "type": "terminal.frame", + "seq": frame.seq, + "encoding": "ansi", + "width": frame.width, + "height": frame.height, + "full": frame.full, + "bytes": encoded, + }); + serde_json::to_writer(&mut stdout, &line)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + } + Ok(ServerMessage::ServerShutdown { reason }) => { + let line = serde_json::json!({ + "type": "terminal.closed", + "reason": reason, + }); + serde_json::to_writer(&mut stdout, &line)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + return Ok(()); + } + Ok(ServerMessage::Graphics { .. }) => {} + Ok(_) => {} + Err(protocol::FramingError::UnexpectedEof) => return Ok(()), + Err(err) => return Err(io::Error::other(err.to_string())), + } + } +} + +#[derive(serde::Deserialize)] +#[serde(tag = "type")] +enum TerminalControlCommand { + #[serde(rename = "terminal.input")] + Input { + text: Option, + bytes: Option, + }, + #[serde(rename = "terminal.resize")] + Resize { + cols: u16, + rows: u16, + #[serde(default)] + cell_width_px: u32, + #[serde(default)] + cell_height_px: u32, + }, + #[serde(rename = "terminal.scroll")] + Scroll { + direction: TerminalControlScrollDirection, + lines: u16, + #[serde(default)] + source: TerminalControlScrollSource, + #[serde(default)] + column: Option, + #[serde(default)] + row: Option, + #[serde(default)] + modifiers: u8, + }, + #[serde(rename = "terminal.release")] + Release {}, +} + +#[derive(Clone, Copy, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum TerminalControlScrollDirection { + Up, + Down, +} + +#[derive(Clone, Copy, Default, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum TerminalControlScrollSource { + #[default] + Wheel, + PageKey, +} + +pub(super) fn terminal_control_command_from_json(raw: &str) -> Result { + let command = serde_json::from_str::(raw) + .map_err(|err| format!("invalid json command: {err}"))?; + match command { + TerminalControlCommand::Input { text, bytes } => { + let data = match (text, bytes) { + (Some(_), Some(_)) => { + return Err("terminal.input accepts text or bytes, not both".into()) + } + (Some(text), None) => text.into_bytes(), + (None, Some(bytes)) => base64::engine::general_purpose::STANDARD + .decode(bytes) + .map_err(|err| format!("invalid terminal.input bytes: {err}"))?, + (None, None) => Vec::new(), + }; + Ok(ClientMessage::Input { data }) + } + TerminalControlCommand::Resize { + cols, + rows, + cell_width_px, + cell_height_px, + } => { + if cols == 0 || rows == 0 { + return Err("terminal.resize cols and rows must be greater than 0".into()); + } + Ok(ClientMessage::Resize { + cols, + rows, + cell_width_px, + cell_height_px, + pixel_mouse: false, + }) + } + TerminalControlCommand::Scroll { + direction, + lines, + source, + column, + row, + modifiers, + } => { + if lines == 0 { + return Err("terminal.scroll lines must be greater than 0".into()); + } + let direction = match direction { + TerminalControlScrollDirection::Up => AttachScrollDirection::Up, + TerminalControlScrollDirection::Down => AttachScrollDirection::Down, + }; + let source = match source { + TerminalControlScrollSource::Wheel => AttachScrollSource::Wheel, + TerminalControlScrollSource::PageKey => AttachScrollSource::PageKey { + input: match direction { + AttachScrollDirection::Up => b"\x1b[5~".to_vec(), + AttachScrollDirection::Down => b"\x1b[6~".to_vec(), + }, + }, + }; + Ok(ClientMessage::AttachScroll { + source, + direction, + lines, + column, + row, + modifiers, + }) + } + TerminalControlCommand::Release {} => Ok(ClientMessage::Detach), + } +} diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs new file mode 100644 index 00000000..9c035346 --- /dev/null +++ b/src/client/terminal_setup.rs @@ -0,0 +1,445 @@ +//! Terminal setup and restoration for the rendered client. + +use std::io::{self, Write as _}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crossterm::event::{ + DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, + EnableFocusChange, EnableMouseCapture, +}; +#[cfg(not(windows))] +use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}; +use crossterm::execute; +use crossterm::terminal::{DisableLineWrap, EnableLineWrap}; + +use super::frame_output::clear_received_kitty_graphics; +use super::terminal_geometry::should_query_host_terminal_theme; + +// --------------------------------------------------------------------------- +// Terminal setup / restore +// --------------------------------------------------------------------------- + +/// Sets up the terminal for client mode (raw mode, optional mouse, keyboard enhancements). +/// +/// Returns a guard that restores the terminal when dropped. +pub(super) fn setup_terminal(mouse_capture: bool) -> io::Result { + setup_terminal_with_capabilities(true, mouse_capture) +} + +/// Sets up a direct attach terminal. +/// +/// Direct attach forwards stdin to the attached PTY. When configured, mouse +/// capture lets wheel events drive the attached viewport or reach child +/// programs that requested mouse input. +pub(super) fn setup_direct_attach_terminal(mouse_capture: bool) -> io::Result { + setup_terminal_with_capabilities(false, mouse_capture) +} + +pub(super) fn setup_terminal_with_capabilities( + enable_client_protocols: bool, + mouse_capture: bool, +) -> io::Result { + ratatui::init(); + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + let host_color_scheme_reports = + should_enable_host_color_scheme_reports(enable_client_protocols); + + #[cfg(windows)] + let windows_ssh_session = is_ssh_session(); + #[cfg(windows)] + let mut windows_virtual_terminal_input = + if windows_vti_input_backend_enabled() && windows_ssh_session { + enable_windows_virtual_terminal_input() + } else { + WindowsVirtualTerminalInputSetup::default() + }; + + if enable_client_protocols { + set_mouse_capture(mouse_capture, false)?; + execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; + if host_color_scheme_reports { + write_host_color_scheme_report_mode(&mut io::stdout(), true)?; + } + push_keyboard_enhancement_flags()?; + } else { + if should_query_host_terminal_theme() { + write_host_color_scheme_report_mode(&mut io::stdout(), false)?; + } + set_mouse_capture(mouse_capture, false)?; + execute!(io::stdout(), EnableBracketedPaste)?; + } + + #[cfg(windows)] + if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { + windows_virtual_terminal_input = enable_windows_virtual_terminal_input(); + } + + #[cfg(windows)] + if enable_client_protocols + && windows_vti_input_backend_enabled() + && windows_virtual_terminal_input.active + && windows_win32_input_mode_enabled() + { + if let Err(err) = enable_windows_win32_input_mode(&mut io::stdout()) { + if let Some(mode) = windows_virtual_terminal_input.restore_mode { + restore_windows_input_mode_value(mode); + } + return Err(err); + } + } + + let modify_other_keys_mode = enable_client_protocols + .then(crate::input::host_modify_other_keys_mode) + .flatten(); + if let Some(mode) = modify_other_keys_mode { + io::stdout().write_all(mode.set_sequence())?; + io::stdout().flush()?; + } + + execute!(io::stdout(), DisableLineWrap)?; + + Ok(TerminalGuard { + reset_keyboard_enhancements: enable_client_protocols, + reset_modify_other_keys: modify_other_keys_mode.is_some(), + reset_host_color_scheme_reports: host_color_scheme_reports, + restore_claimed: Arc::new(AtomicBool::new(false)), + restored: false, + #[cfg(windows)] + restore_windows_input_mode: windows_virtual_terminal_input.restore_mode, + }) +} + +pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool { + enable_client_protocols && should_query_host_terminal_theme() +} + +/// Guard that restores the terminal when dropped. +pub(super) struct TerminalGuard { + reset_keyboard_enhancements: bool, + reset_modify_other_keys: bool, + reset_host_color_scheme_reports: bool, + restore_claimed: Arc, + restored: bool, + #[cfg(windows)] + restore_windows_input_mode: Option, +} + +pub(super) fn write_host_color_scheme_report_mode( + writer: &mut impl io::Write, + enabled: bool, +) -> io::Result<()> { + let sequence = if enabled { + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE + } else { + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE + }; + writer.write_all(sequence.as_bytes())?; + writer.flush() +} + +pub(super) fn write_terminal_restore_postlude( + writer: &mut impl io::Write, + reset_host_color_scheme_reports: bool, +) -> io::Result<()> { + if reset_host_color_scheme_reports { + writer.write_all( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), + )?; + } + // Restore a visible cursor and reset DECSCUSR back to the terminal default. + writer.write_all(b"\x1b[?25h\x1b[0 q")?; + writer.flush() +} + +pub(super) fn should_draw_host_cursor(mode: crate::config::HostCursorModeConfig) -> bool { + match mode { + crate::config::HostCursorModeConfig::Auto => { + crate::platform::should_draw_host_cursor_by_default() + } + crate::config::HostCursorModeConfig::Native => false, + crate::config::HostCursorModeConfig::Drawn => true, + } +} + +#[cfg(windows)] +#[derive(Default)] +pub(super) struct WindowsVirtualTerminalInputSetup { + active: bool, + restore_mode: Option, +} + +#[cfg(windows)] +pub(super) fn enable_windows_virtual_terminal_input() -> WindowsVirtualTerminalInputSetup { + use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Console::{ + GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT, + STD_INPUT_HANDLE, + }; + + let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + tracing::warn!("failed to get Windows console input handle for VT input"); + return WindowsVirtualTerminalInputSetup::default(); + } + + let mut mode = 0; + if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { + tracing::warn!("failed to read Windows console input mode for VT input"); + return WindowsVirtualTerminalInputSetup::default(); + } + + let desired = windows_virtual_terminal_input_mode(mode); + if desired == mode { + return WindowsVirtualTerminalInputSetup { + active: true, + restore_mode: None, + }; + } + + if unsafe { SetConsoleMode(handle, desired) } == 0 { + tracing::warn!("failed to enable Windows virtual terminal input"); + return WindowsVirtualTerminalInputSetup::default(); + } + + let mut applied = 0; + if unsafe { GetConsoleMode(handle, &mut applied) } == 0 { + tracing::warn!("failed to verify Windows virtual terminal input mode"); + let _ = unsafe { SetConsoleMode(handle, mode) }; + return WindowsVirtualTerminalInputSetup::default(); + } + if applied & ENABLE_VIRTUAL_TERMINAL_INPUT == 0 { + tracing::warn!("Windows virtual terminal input bit did not stick"); + let _ = unsafe { SetConsoleMode(handle, mode) }; + return WindowsVirtualTerminalInputSetup::default(); + } + + WindowsVirtualTerminalInputSetup { + active: true, + restore_mode: Some(mode), + } +} + +pub(super) fn is_ssh_session() -> bool { + std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() +} + +#[cfg(windows)] +pub(super) fn windows_vti_input_backend_enabled() -> bool { + std::env::var("HERDR_WINDOWS_INPUT_BACKEND") + .map(|backend| !backend.eq_ignore_ascii_case("crossterm")) + .unwrap_or(true) +} + +#[cfg(any(windows, test))] +pub(super) fn windows_virtual_terminal_input_mode(mode: u32) -> u32 { + mode | 0x0200 +} + +#[cfg(windows)] +fn restore_windows_input_mode_value(mode: u32) { + use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Console::{GetStdHandle, SetConsoleMode, STD_INPUT_HANDLE}; + + let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return; + } + if unsafe { SetConsoleMode(handle, mode) } == 0 { + tracing::warn!("failed to restore Windows console input mode"); + } +} + +pub(super) fn effective_mouse_capture( + server_enabled: bool, + direct_attach_preference: bool, +) -> bool { + server_enabled || direct_attach_preference +} + +pub(super) fn effective_sgr_pixel_mouse( + enabled: bool, + requested: bool, + exact_geometry: bool, +) -> bool { + enabled && requested && exact_geometry +} + +pub(super) fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<()> { + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + #[cfg(windows)] + if is_ssh_session() && windows_vti_input_backend_enabled() { + return crate::terminal_modes::set_windows_ssh_mouse_reporting( + &mut io::stdout(), + enabled, + sgr_pixels, + ); + } + if enabled { + execute!(io::stdout(), EnableMouseCapture)?; + if sgr_pixels { + io::stdout().write_all(b"\x1b[?1016h")?; + io::stdout().flush()?; + } + Ok(()) + } else { + match execute!(io::stdout(), DisableMouseCapture) { + Ok(()) => Ok(()), + #[cfg(windows)] + Err(err) if err.to_string() == "Initial console modes not set" => Ok(()), + Err(err) => Err(err), + } + } +} + +fn restore_terminal_state_once( + restore_claimed: &AtomicBool, + reset_keyboard_enhancements: bool, + reset_modify_other_keys: bool, + reset_host_color_scheme_reports: bool, + #[cfg(windows)] restore_windows_input_mode: Option, +) -> io::Result<()> { + if restore_claimed.swap(true, Ordering::AcqRel) { + return Ok(()); + } + restore_terminal_state( + reset_keyboard_enhancements, + reset_modify_other_keys, + reset_host_color_scheme_reports, + #[cfg(windows)] + restore_windows_input_mode, + ) +} + +fn restore_terminal_state( + reset_keyboard_enhancements: bool, + reset_modify_other_keys: bool, + reset_host_color_scheme_reports: bool, + #[cfg(windows)] restore_windows_input_mode: Option, +) -> io::Result<()> { + let _ = clear_received_kitty_graphics(&mut io::stdout()); + + // Reset modifyOtherKeys if we enabled it. + if reset_modify_other_keys { + let _ = io::stdout().write_all(b"\x1b[>4;0m"); + let _ = io::stdout().flush(); + } + + if reset_keyboard_enhancements { + let _ = pop_keyboard_enhancement_flags(); + } + + let _ = execute!( + io::stdout(), + EnableLineWrap, + DisableFocusChange, + DisableBracketedPaste + ); + let _ = set_mouse_capture(false, false); + #[cfg(windows)] + if let Some(mode) = restore_windows_input_mode { + restore_windows_input_mode_value(mode); + } + + let restore_result = ratatui::try_restore(); + let postlude_result = + write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports); + + #[cfg(windows)] + if windows_vti_input_backend_enabled() && windows_win32_input_mode_enabled() { + let _ = disable_windows_win32_input_mode(&mut io::stdout()); + } + + restore_result.and(postlude_result) +} + +#[cfg(not(windows))] +fn push_keyboard_enhancement_flags() -> io::Result<()> { + execute!( + io::stdout(), + PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) + ) +} + +#[cfg(windows)] +fn push_keyboard_enhancement_flags() -> io::Result<()> { + Ok(()) +} + +#[cfg(not(windows))] +fn pop_keyboard_enhancement_flags() -> io::Result<()> { + execute!(io::stdout(), PopKeyboardEnhancementFlags) +} + +#[cfg(windows)] +fn pop_keyboard_enhancement_flags() -> io::Result<()> { + Ok(()) +} + +#[cfg(windows)] +fn windows_win32_input_mode_enabled() -> bool { + std::env::var("HERDR_WINDOWS_INPUT_PROBE") + .map(|probe| probe.eq_ignore_ascii_case("win32")) + .unwrap_or(true) +} + +#[cfg(windows)] +fn enable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { + writer.write_all(b"\x1b[?9001h")?; + writer.flush() +} + +#[cfg(windows)] +fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { + writer.write_all(b"\x1b[?9001l")?; + writer.flush() +} + +impl TerminalGuard { + /// Captures the restoration state for use by the process panic hook. + pub(super) fn panic_restore(&self) -> impl Fn() + Send + Sync + 'static { + let restore_claimed = self.restore_claimed.clone(); + let reset_keyboard_enhancements = self.reset_keyboard_enhancements; + let reset_modify_other_keys = self.reset_modify_other_keys; + let reset_host_color_scheme_reports = self.reset_host_color_scheme_reports; + #[cfg(windows)] + let restore_windows_input_mode = self.restore_windows_input_mode; + move || { + let _ = restore_terminal_state_once( + &restore_claimed, + reset_keyboard_enhancements, + reset_modify_other_keys, + reset_host_color_scheme_reports, + #[cfg(windows)] + restore_windows_input_mode, + ); + } + } + + pub(super) fn restore(mut self) -> io::Result<()> { + self.restored = true; + restore_terminal_state_once( + &self.restore_claimed, + self.reset_keyboard_enhancements, + self.reset_modify_other_keys, + self.reset_host_color_scheme_reports, + #[cfg(windows)] + self.restore_windows_input_mode, + ) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if !self.restored { + let _ = restore_terminal_state_once( + &self.restore_claimed, + self.reset_keyboard_enhancements, + self.reset_modify_other_keys, + self.reset_host_color_scheme_reports, + #[cfg(windows)] + self.restore_windows_input_mode, + ); + } + } +} diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs new file mode 100644 index 00000000..ff4b343c --- /dev/null +++ b/src/client/tests/mod.rs @@ -0,0 +1,817 @@ +use super::*; +use std::ffi::OsString; +use std::sync::{Mutex, OnceLock}; + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +#[test] +fn resize_signal_reports_even_when_polled_size_is_unchanged() { + let size = (120, 40, 8, 16, true); + assert!(resize_report_required(true, size, size)); + assert!(!resize_report_required(false, size, size)); + assert!(resize_report_required(false, (120, 41, 8, 16, true), size)); + assert!(resize_report_required(false, (120, 40, 9, 18, true), size)); + assert!(resize_report_required(false, (120, 40, 8, 16, false), size)); +} + +#[test] +fn direct_graphics_profile_is_narrow_and_transport_safe() { + for (program, term, kitty, expected) in [ + ("ghostty", "", false, true), + ("WezTerm", "", false, true), + ("", "xterm-kitty", false, true), + ("", "xterm-256color", true, true), + ("", "xterm-256color", false, false), + ] { + assert_eq!( + direct_graphics_profile_values(program, term, kitty, false, true), + expected + ); + } + assert!(!direct_graphics_profile_values( + "ghostty", "", false, true, true + )); + assert!(!direct_graphics_profile_values( + "ghostty", "", false, false, false + )); +} + +fn restore_env_var(key: &str, value: Option) { + if let Some(value) = value { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } +} + +struct EnvVarGuard { + key: &'static str, + previous: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + restore_env_var(self.key, self.previous.clone()); + } +} + +#[test] +fn windows_virtual_terminal_input_mode_sets_only_vti_bit() { + assert_eq!(windows_virtual_terminal_input_mode(0x01f0), 0x03f0); + assert_eq!(windows_virtual_terminal_input_mode(0x03f0), 0x03f0); +} + +struct EnvVarsRemovedGuard { + previous: Vec<(&'static str, Option)>, +} + +impl EnvVarsRemovedGuard { + fn new(keys: &[&'static str]) -> Self { + let previous: Vec<_> = keys + .iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(); + for key in keys { + std::env::remove_var(key); + } + Self { previous } + } +} + +impl Drop for EnvVarsRemovedGuard { + fn drop(&mut self) { + for (key, value) in self.previous.clone() { + restore_env_var(key, value); + } + } +} + +#[test] +fn remote_client_uses_extended_handshake_timeout() { + let _guard = env_lock().lock().unwrap(); + let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local"); + + assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT); +} + +#[test] +fn host_cursor_policy_auto_uses_platform_default() { + assert_eq!( + should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto), + crate::platform::should_draw_host_cursor_by_default() + ); +} + +#[test] +fn host_cursor_policy_native_and_drawn_override_auto_detection() { + let _guard = env_lock().lock().unwrap(); + let _env = EnvVarGuard::set("TERM_PROGRAM", "WezTerm"); + + assert!(!should_draw_host_cursor( + crate::config::HostCursorModeConfig::Native + )); + assert!(should_draw_host_cursor( + crate::config::HostCursorModeConfig::Drawn + )); +} + +#[cfg(unix)] +#[test] +fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { + let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); + assert!(should_bridge_clipboard_image_paste( + &[0x16], + true, + Some(ctrl_v) + )); + assert!(should_bridge_clipboard_image_paste( + b"\x1b[118;5u", + true, + Some(ctrl_v) + )); + assert!(should_bridge_clipboard_image_paste( + b"\x1b[200~\x1b[201~", + true, + None + )); + assert!(!should_bridge_clipboard_image_paste( + b"\x1b[200~\x1b[201~", + false, + Some(ctrl_v) + )); + assert!(!should_bridge_clipboard_image_paste( + b"\x1b[200~text\x1b[201~", + true, + Some(ctrl_v) + )); + assert!(!should_bridge_clipboard_image_paste(&[0x16], true, None)); + assert!(!should_bridge_clipboard_image_paste( + b"v", + true, + Some(ctrl_v) + )); +} + +struct TempImageFile { + path: std::path::PathBuf, +} + +impl TempImageFile { + fn new(extension: &str, bytes: &[u8]) -> Self { + Self::with_name_fragment("test", extension, bytes) + } + + fn with_name_fragment(name_fragment: &str, extension: &str, bytes: &[u8]) -> Self { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "herdr-client-drop-{name_fragment}-{}-{nanos}.{extension}", + std::process::id() + )); + std::fs::write(&path, bytes).unwrap(); + Self { path } + } +} + +impl Drop for TempImageFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} +#[cfg(unix)] +#[test] +fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() { + let file = TempImageFile::new("PNG", b"image-bytes"); + let input = format!("\x1b[200~{}\x1b[201~", file.path.display()); + + let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); + + assert_eq!(image.extension, "png"); + assert_eq!(image.bytes, b"image-bytes"); +} + +#[cfg(unix)] +#[test] +fn remote_image_file_drop_bridge_reads_plain_quoted_path_with_newline() { + let file = TempImageFile::new("jpeg", b"jpeg-bytes"); + let input = format!("'{}'\n", file.path.display()); + + let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); + + assert_eq!(image.extension, "jpg"); + assert_eq!(image.bytes, b"jpeg-bytes"); +} + +#[cfg(unix)] +#[test] +fn remote_image_file_drop_bridge_unescapes_spaces_in_paths() { + let file = TempImageFile::with_name_fragment("space test", "png", b"image-bytes"); + let escaped_path = file.path.display().to_string().replace(' ', "\\ "); + + let image = read_image_file_from_terminal_drop(escaped_path.as_bytes(), true).unwrap(); + + assert_eq!(image.extension, "png"); + assert_eq!(image.bytes, b"image-bytes"); +} + +#[cfg(unix)] +#[test] +fn remote_image_file_drop_bridge_ignores_non_remote_and_non_image_input() { + let file = TempImageFile::new("png", b"image-bytes"); + let path = file.path.display().to_string(); + + assert!(read_image_file_from_terminal_drop(path.as_bytes(), false).is_none()); + assert!(read_image_file_from_terminal_drop(b"relative.png\n", true).is_none()); + assert!(read_image_file_from_terminal_drop(b"/tmp/file.txt\n", true).is_none()); + assert!(read_image_file_from_terminal_drop( + format!("{}\nextra", file.path.display()).as_bytes(), + true + ) + .is_none()); +} + +#[test] +fn graphics_bytes_are_written_inside_synchronized_blit_with_saved_cursor() { + let mut output = Vec::new(); + write_encoded_frame_with_graphics( + &mut output, + b"\x1b[?2026htext\x1b[?2026lcursor", + b"graphics", + ) + .unwrap(); + + assert_eq!( + output, + b"\x1b[?2026htext\x1b7graphics\x1b8\x1b[?2026lcursor" + ); +} + +#[test] +fn empty_graphics_writes_only_blit_frame() { + let mut output = Vec::new(); + write_encoded_frame_with_graphics(&mut output, b"text", b"").unwrap(); + + assert_eq!(output, b"text"); +} + +#[test] +fn terminal_frame_kitty_detection_matches_apc_prefix() { + assert!(contains_kitty_graphics_bytes(b"text\x1b_Ga=p;\x1b\\")); + assert!(!contains_kitty_graphics_bytes(b"text\x1b[?2026h")); +} + +#[test] +fn kitty_graphics_image_id_parser_tracks_herdr_ids_only() { + let ids = kitty_graphics_image_ids( + b"text\x1b_Ga=t,t=d,f=32,s=1,v=1,i=10023,q=2;AAAA\x1b\\\x1b_Ga=p,i=10023,p=7;\x1b\\", + ); + assert_eq!(ids, vec![10023, 10023]); +} + +#[test] +fn kitty_graphics_cleanup_deletes_tracked_images_not_all_images() { + record_received_kitty_graphics(b"\x1b_Ga=t,i=123,q=2;AAAA\x1b\\"); + let mut output = Vec::new(); + clear_received_kitty_graphics(&mut output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("a=d,d=I,i=123")); + assert!(!text.contains("d=A")); +} + +#[test] +fn write_host_terminal_appearance_query_emits_mode_2031_query() { + let mut output = Vec::new(); + write_host_terminal_appearance_query(&mut output).unwrap(); + assert_eq!(output, b"\x1b[?996n"); +} + +#[test] +fn write_host_terminal_theme_query_emits_osc_queries() { + let mut output = Vec::new(); + write_host_terminal_theme_query(&mut output).unwrap(); + assert_eq!( + output, + crate::terminal_theme::host_terminal_theme_query_sequence( + crate::platform::should_query_host_terminal_palette(), + ) + .as_bytes() + ); + assert!( + !output + .windows(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.len()) + .any(|window| window + == crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes()) + ); +} + +#[test] +fn write_host_color_scheme_report_mode_emits_mode_sequences() { + let mut output = Vec::new(); + write_host_color_scheme_report_mode(&mut output, true).unwrap(); + write_host_color_scheme_report_mode(&mut output, false).unwrap(); + + let mut expected = Vec::new(); + expected.extend_from_slice( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE.as_bytes(), + ); + expected.extend_from_slice( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), + ); + assert_eq!(output, expected); +} + +#[test] +fn color_scheme_change_event_requests_host_theme_query() { + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); + + assert!(crate::raw_input::events_require_host_terminal_theme_query( + &events + )); +} + +#[test] +fn host_terminal_theme_query_is_disabled_on_windows() { + assert_eq!(should_query_host_terminal_theme(), !cfg!(windows)); +} + +#[test] +fn write_host_cell_size_query_emits_xtwinops_request() { + let mut output = Vec::new(); + write_host_cell_size_query(&mut output).unwrap(); + + assert_eq!(output, b"\x1b[16t"); +} + +#[test] +fn host_cell_size_query_is_disabled_on_windows() { + assert_eq!(should_query_host_cell_size(), !cfg!(windows)); +} + +#[test] +fn cell_size_fallback_prefers_reported_then_previous_size() { + assert_eq!(cell_size_fallback(0, None), (8, 16)); + assert_eq!(cell_size_fallback(0, Some((11, 22))), (11, 22)); + assert_eq!( + cell_size_fallback(pack_cell_size(10, 21), Some((11, 22))), + (10, 21) + ); + assert_eq!(cell_size_fallback(pack_cell_size(10, 0), None), (8, 16)); + assert_eq!(cell_size_fallback(pack_cell_size(0, 21), None), (8, 16)); +} + +#[test] +fn reported_cell_size_is_taken_from_host_cell_size_events() { + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); + assert_eq!(reported_cell_size_from_events(&events), None); + + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[6;21;10t\x1b[6;18;9t"); + assert_eq!(reported_cell_size_from_events(&events), Some((9, 18))); +} + +#[test] +fn color_scheme_reports_are_enabled_only_for_full_clients() { + assert_eq!( + should_enable_host_color_scheme_reports(true), + !cfg!(windows) + ); + assert!(!should_enable_host_color_scheme_reports(false)); +} + +#[test] +fn terminal_restore_postlude_restores_visible_default_cursor() { + let mut output = Vec::new(); + write_terminal_restore_postlude(&mut output, false).unwrap(); + assert_eq!(output, b"\x1b[?25h\x1b[0 q"); +} + +#[test] +fn direct_attach_mouse_capture_combines_local_preference_with_child_demand() { + assert!(effective_mouse_capture(false, true)); + assert!(effective_mouse_capture(true, false)); + assert!(!effective_mouse_capture(false, false)); + assert!(effective_sgr_pixel_mouse(true, true, true)); + assert!(!effective_sgr_pixel_mouse(true, true, false)); +} + +#[test] +fn terminal_restore_postlude_disables_color_scheme_reports_when_enabled() { + let mut output = Vec::new(); + write_terminal_restore_postlude(&mut output, true).unwrap(); + + let mut expected = Vec::new(); + expected.extend_from_slice( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), + ); + expected.extend_from_slice(b"\x1b[?25h\x1b[0 q"); + assert_eq!(output, expected); +} + +#[test] +fn client_error_display_connection_failed() { + let err = ClientError::ConnectionFailed(io::Error::new( + io::ErrorKind::ConnectionRefused, + "connection refused", + )); + let msg = err.to_string(); + assert!( + msg.contains("failed to connect to server"), + "should mention connection failure: {msg}" + ); + assert!( + msg.contains("herdr server"), + "should suggest starting server: {msg}" + ); +} + +#[test] +fn client_error_display_handshake_rejected() { + let err = ClientError::HandshakeRejected { + version: 1, + error: "incompatible".into(), + }; + let msg = err.to_string(); + assert!( + msg.contains("rejected handshake"), + "should mention rejection: {msg}" + ); + assert!(msg.contains("incompatible"), "should include error: {msg}"); +} + +#[test] +fn client_error_display_server_shutdown() { + let err = ClientError::ServerShutdown { + reason: Some("maintenance".into()), + }; + let msg = err.to_string(); + assert!( + msg.contains("server shut down"), + "should mention shutdown: {msg}" + ); + assert!(msg.contains("maintenance"), "should include reason: {msg}"); +} + +#[test] +fn client_error_display_server_shutdown_no_reason() { + let err = ClientError::ServerShutdown { reason: None }; + let msg = err.to_string(); + assert!( + msg.contains("server shut down"), + "should mention shutdown: {msg}" + ); +} + +#[test] +fn client_error_display_detached_default_session_reattach_hint() { + let _guard = env_lock().lock().unwrap(); + let _env = EnvVarsRemovedGuard::new(&[ + crate::remote::REATTACH_COMMAND_ENV_VAR, + crate::session::SESSION_ENV_VAR, + ]); + let err = ClientError::ServerShutdown { + reason: Some("detached".into()), + }; + let msg = err.to_string(); + assert!( + msg.contains("Run `herdr` to reattach"), + "should suggest default reattach command: {msg}" + ); +} + +#[test] +fn client_error_display_detached_named_session_reattach_hint() { + let _guard = env_lock().lock().unwrap(); + let _remote_env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); + let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); + let err = ClientError::ServerShutdown { + reason: Some("detached".into()), + }; + let msg = err.to_string(); + assert!( + msg.contains("Run `herdr session attach work` to reattach"), + "should suggest named session reattach command: {msg}" + ); +} + +#[test] +fn client_error_display_detached_remote_reattach_hint_takes_precedence() { + let _guard = env_lock().lock().unwrap(); + let _remote_env = EnvVarGuard::set( + crate::remote::REATTACH_COMMAND_ENV_VAR, + "herdr --remote host --session work", + ); + let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); + let err = ClientError::ServerShutdown { + reason: Some("detached".into()), + }; + let msg = err.to_string(); + assert!( + msg.contains("Run `herdr --remote host --session work` to reattach"), + "should prefer remote reattach command: {msg}" + ); +} + +#[test] +fn client_error_display_connection_lost() { + let _guard = env_lock().lock().unwrap(); + let _env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); + let err = ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); + let msg = err.to_string(); + assert!( + msg.contains("lost connection to server"), + "should mention lost connection: {msg}" + ); +} + +#[test] +fn client_error_display_remote_connection_lost_has_reattach_hint() { + let _guard = env_lock().lock().unwrap(); + let _remote_env = EnvVarGuard::set( + crate::remote::REATTACH_COMMAND_ENV_VAR, + "herdr --remote host --session work", + ); + let err = ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); + let msg = err.to_string(); + assert!( + msg.contains("lost connection to remote Herdr"), + "should mention remote connection loss: {msg}" + ); + assert!( + msg.contains("panes may still be running"), + "should explain possible persistence: {msg}" + ); + assert!( + msg.contains("Run `herdr --remote host --session work` to reattach"), + "should show remote reattach command: {msg}" + ); +} + +#[test] +fn sound_from_notify_message_maps_done() { + assert_eq!( + sound_from_notify_message("agent done"), + Some(crate::sound::Sound::Done) + ); +} + +#[test] +fn sound_from_notify_message_maps_attention() { + assert_eq!( + sound_from_notify_message("agent attention"), + Some(crate::sound::Sound::Request) + ); +} + +#[test] +fn sound_from_notify_message_rejects_unknown_payloads() { + assert_eq!(sound_from_notify_message("toast"), None); +} + +#[test] +fn reload_local_client_config_refreshes_local_client_presentation_state() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "herdr-client-config-reload-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write( + &path, + "[ui]\nredraw_on_focus_gained = false\nhost_cursor = \"drawn\"\nmouse_capture = false\n", + ) + .unwrap(); + let path_string = path.to_string_lossy().to_string(); + let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string); + let mut sound_config = crate::config::SoundConfig::default(); + let mut redraw_on_focus_gained = true; + let mut draw_host_cursor = false; + let mut remote_image_paste_key = None; + let mut mouse_capture = true; + + reload_local_client_config( + &mut sound_config, + &mut redraw_on_focus_gained, + &mut draw_host_cursor, + &mut remote_image_paste_key, + &mut mouse_capture, + ); + + assert!(!redraw_on_focus_gained); + assert!(draw_host_cursor); + assert!(!mouse_capture); + let _ = std::fs::remove_file(path); +} + +#[test] +fn reload_local_client_config_keeps_ui_preferences_when_ui_is_invalid() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "herdr-client-invalid-ui-reload-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, "[ui]\nmouse_capture = \"invalid\"\n").unwrap(); + let path_string = path.to_string_lossy().to_string(); + let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string); + let mut sound_config = crate::config::SoundConfig::default(); + let mut redraw_on_focus_gained = false; + let mut draw_host_cursor = true; + let mut remote_image_paste_key = None; + let mut mouse_capture = false; + + reload_local_client_config( + &mut sound_config, + &mut redraw_on_focus_gained, + &mut draw_host_cursor, + &mut remote_image_paste_key, + &mut mouse_capture, + ); + + assert!(!mouse_capture); + assert!(!redraw_on_focus_gained); + assert!(draw_host_cursor); + let _ = std::fs::remove_file(path); +} + +#[test] +fn toast_notify_from_server_is_emitted_even_when_attach_config_was_off() { + let sound_config = crate::config::SoundConfig::default(); + let mut emitted = None; + + handle_notify_with_notifiers( + NotifyKind::Toast, + "pi finished", + Some("workspace 1"), + &sound_config, + |title, body| { + emitted = Some((title.to_string(), body.map(str::to_string))); + Ok(true) + }, + |_, _| Ok(false), + ); + + assert_eq!( + emitted, + Some(("pi finished".to_string(), Some("workspace 1".to_string()))) + ); +} + +#[test] +fn system_toast_notify_from_server_uses_system_notifier() { + let sound_config = crate::config::SoundConfig::default(); + let mut emitted = None; + + handle_notify_with_notifiers( + NotifyKind::SystemToast, + "pi finished", + Some("workspace 1"), + &sound_config, + |_, _| Ok(false), + |title, body| { + emitted = Some((title.to_string(), body.map(str::to_string))); + Ok(true) + }, + ); + + assert_eq!( + emitted, + Some(("pi finished".to_string(), Some("workspace 1".to_string()))) + ); +} + +#[test] +fn system_toast_notify_preserves_colon_in_title() { + let sound_config = crate::config::SoundConfig::default(); + let mut emitted = None; + + handle_notify_with_notifiers( + NotifyKind::SystemToast, + "build: failed", + Some("api workspace"), + &sound_config, + |_, _| Ok(false), + |title, body| { + emitted = Some((title.to_string(), body.map(str::to_string))); + Ok(true) + }, + ); + + assert_eq!( + emitted, + Some(( + "build: failed".to_string(), + Some("api workspace".to_string()) + )) + ); +} + +#[test] +fn decode_clipboard_payload_decodes_base64() { + assert_eq!(decode_clipboard_payload("dGVzdA=="), Some(b"test".to_vec())); +} + +#[test] +fn ioctl_cell_size_accepts_fractional_terminal_geometry() { + assert_eq!(ioctl_cell_size(80, 24, 800, 480), Some((10, 20))); + assert_eq!(ioctl_cell_size(80, 24, 805, 480), Some((10, 20))); + assert_eq!(ioctl_cell_size(80, 24, 800, 485), Some((10, 20))); + assert_eq!(ioctl_cell_size(80, 24, 0, 485), None); +} + +#[test] +fn decode_clipboard_payload_rejects_invalid_base64() { + assert_eq!(decode_clipboard_payload("not-base64!!!"), None); +} + +#[test] +fn terminal_control_input_command_accepts_text() { + let action = + terminal_control_command_from_json(r#"{"type":"terminal.input","text":"hello"}"#).unwrap(); + let ClientMessage::Input { data } = action else { + panic!("expected input command"); + }; + assert_eq!(data, b"hello"); +} + +#[test] +fn terminal_control_input_command_accepts_base64_bytes() { + let action = + terminal_control_command_from_json(r#"{"type":"terminal.input","bytes":"G1tB"}"#).unwrap(); + let ClientMessage::Input { data } = action else { + panic!("expected input command"); + }; + assert_eq!(data, b"\x1b[A"); +} + +#[test] +fn terminal_control_resize_command_maps_to_client_resize() { + let action = terminal_control_command_from_json( + r#"{"type":"terminal.resize","cols":100,"rows":30,"cell_width_px":8,"cell_height_px":16}"#, + ) + .unwrap(); + let ClientMessage::Resize { + cols, + rows, + cell_width_px, + cell_height_px, + pixel_mouse, + } = action + else { + panic!("expected resize command"); + }; + assert_eq!( + (cols, rows, cell_width_px, cell_height_px), + (100, 30, 8, 16) + ); + assert!(!pixel_mouse); +} + +#[test] +fn terminal_control_scroll_command_maps_to_attach_scroll() { + let action = terminal_control_command_from_json( + r#"{"type":"terminal.scroll","direction":"up","lines":3}"#, + ) + .unwrap(); + let ClientMessage::AttachScroll { + source, + direction, + lines, + .. + } = action + else { + panic!("expected scroll command"); + }; + assert_eq!(source, AttachScrollSource::Wheel); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 3); +} + +#[test] +fn forward_clipboard_uses_local_clipboard_path() { + unsafe { + std::env::set_var("SSH_CONNECTION", "1 2 3 4"); + } + assert!(forward_clipboard("dGVzdA==")); + assert!(!forward_clipboard("not base64")); + unsafe { + std::env::remove_var("SSH_CONNECTION"); + } +} diff --git a/src/config.rs b/src/config.rs index 9d1cdca9..117374ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,7 +39,7 @@ pub use self::{ }; pub(crate) use self::keybinds::parse_key_combo; -pub(crate) use self::write::{update_file, write_edit, ConfigEdit}; +pub(crate) use self::write::{update_file_at, write_edit, ConfigEdit}; pub(crate) use self::{ io::upsert_top_level_bool, tab_bar::{ @@ -157,12 +157,6 @@ impl Config { }) } - #[cfg(test)] - pub fn live_keybinds(&self) -> Result> { - self.live_keybinds_with_diagnostics() - .map(|(live, _diagnostics)| live) - } - pub(crate) fn live_keybinds_with_diagnostics( &self, ) -> Result<(LiveKeybindConfig, Vec), Vec> { diff --git a/src/config/model.rs b/src/config/model.rs index e94717f8..552c19de 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -101,15 +101,6 @@ pub enum AgentPanelSortConfig { Priority, } -impl AgentPanelSortConfig { - pub fn as_str(self) -> &'static str { - match self { - Self::Spaces => "spaces", - Self::Priority => "priority", - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] enum LegacyAgentPanelScopeConfig { diff --git a/src/config/write.rs b/src/config/write.rs index a3ad9a4d..75244583 100644 --- a/src/config/write.rs +++ b/src/config/write.rs @@ -4,8 +4,6 @@ pub(crate) enum ConfigEdit<'a> { StatusIndicators(super::StatusIndicatorStyle), Sound(bool), ToastDelivery(super::ToastDelivery), - AgentBorderLabels(bool), - AgentPanelSort(super::AgentPanelSortConfig), } impl ConfigEdit<'_> { @@ -15,8 +13,6 @@ impl ConfigEdit<'_> { Self::StatusIndicators(_) => "status indicators", Self::Sound(_) => "sound setting", Self::ToastDelivery(_) => "toast setting", - Self::AgentBorderLabels(_) => "agent border labels", - Self::AgentPanelSort(_) => "agent panel sort", } } @@ -46,32 +42,20 @@ impl ConfigEdit<'_> { let content = super::upsert_section_value(content, "ui.toast", "delivery", value); super::remove_section_key(&content, "ui.toast", "enabled") } - Self::AgentBorderLabels(enabled) => super::upsert_section_bool( - content, - "ui", - "show_agent_labels_on_pane_borders", - enabled, - ), - Self::AgentPanelSort(sort) => super::upsert_section_value( - content, - "ui", - "agent_panel_sort", - &format!("\"{}\"", sort.as_str()), - ), } } } -pub(crate) fn update_file( +pub(crate) fn update_file_at( + path: &std::path::Path, description: &str, update: impl FnOnce(&str) -> String, ) -> Result<(), String> { - let path = super::config_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|error| format!("failed to create config directory: {error}"))?; } - let content = match std::fs::read_to_string(&path) { + let content = match std::fs::read_to_string(path) { Ok(content) => content, Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), Err(error) => { @@ -80,24 +64,12 @@ pub(crate) fn update_file( )); } }; - std::fs::write(&path, update(&content)) + std::fs::write(path, update(&content)) .map_err(|error| format!("failed to save {description}: {error}")) } pub(crate) fn write_edit(edit: ConfigEdit<'_>) -> Result<(), String> { - update_file(edit.description(), |content| edit.apply(content)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn edits_preserve_unrelated_config() { - let content = "[terminal]\ndefault_shell = \"fish\"\n"; - let edited = - ConfigEdit::AgentPanelSort(super::super::AgentPanelSortConfig::Priority).apply(content); - assert!(edited.contains("default_shell = \"fish\"")); - assert!(edited.contains("agent_panel_sort = \"priority\"")); - } + update_file_at(&super::config_path(), edit.description(), |content| { + edit.apply(content) + }) } diff --git a/src/copy_mode.rs b/src/copy_mode.rs new file mode 100644 index 00000000..8f84b9a8 --- /dev/null +++ b/src/copy_mode.rs @@ -0,0 +1,86 @@ +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::input::TerminalKey; + +pub(crate) fn first_non_blank_col(text: &str) -> Option { + let mut col = 0u16; + for ch in text.chars() { + if !ch.is_whitespace() { + return Some(col); + } + col = col.saturating_add(char_cell_width(ch)); + } + None +} + +pub(crate) fn last_character_col(text: &str) -> Option { + let mut col = 0u16; + let mut last_col = None; + for ch in text.chars() { + let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)); + if width > 0 { + last_col = Some(col); + col = col.saturating_add(width); + } + } + last_col +} + +fn char_cell_width(ch: char) -> u16 { + u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)).max(1) +} + +pub(crate) fn copy_mode_page_lines(height: u16, half_page: bool) -> usize { + if height <= 2 { + 1 + } else if half_page { + usize::from(height / 2) + } else { + usize::from(height - 2) + } +} + +pub(crate) fn copy_mode_command_char(key: TerminalKey) -> Option { + if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { + return None; + } + if let Some(ch) = key.shifted_codepoint.and_then(char::from_u32) { + return Some(ch); + } + let KeyCode::Char(ch) = key.code else { + return None; + }; + if key.modifiers.contains(KeyModifiers::SHIFT) { + Some(shifted_ascii_char(ch).unwrap_or(ch)) + } else { + Some(ch) + } +} + +fn shifted_ascii_char(ch: char) -> Option { + match ch { + 'a'..='z' => Some(ch.to_ascii_uppercase()), + '1' => Some('!'), + '2' => Some('@'), + '3' => Some('#'), + '4' => Some('$'), + '5' => Some('%'), + '6' => Some('^'), + '7' => Some('&'), + '8' => Some('*'), + '9' => Some('('), + '0' => Some(')'), + '-' => Some('_'), + '=' => Some('+'), + '[' => Some('{'), + ']' => Some('}'), + '\\' => Some('|'), + ';' => Some(':'), + '\'' => Some('"'), + ',' => Some('<'), + '.' => Some('>'), + '/' => Some('?'), + '`' => Some('~'), + _ => None, + } +} diff --git a/src/events.rs b/src/events.rs index 65701011..d17ef60b 100644 --- a/src/events.rs +++ b/src/events.rs @@ -137,10 +137,6 @@ pub enum AppEvent { /// A pane child emitted a valid OSC 52 clipboard write. The main loop /// re-emits it through herdr's own clipboard writer. ClipboardWrite { content: Vec }, - /// Prefix-mode ASCII input-source request, emitted on entering/leaving the ASCII input - /// realm. The foreground process applies the host-local TIS switch (`active = true`) / - /// restore (`active = false`): the foreground client applies the forwarded request. - PrefixInputSource { active: bool }, /// A pane child reported its shell current directory through terminal /// metadata such as OSC 7. TerminalCwdReported { diff --git a/src/input/lease.rs b/src/input/lease.rs index 2774687d..0352df42 100644 --- a/src/input/lease.rs +++ b/src/input/lease.rs @@ -396,7 +396,7 @@ mod tests { } #[test] - fn semantic_generated_text_remains_untracked() { + fn forwarded_semantic_generated_text_has_no_release_lease() { let key = TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) .with_generated_text(Some("/".to_owned())) .with_repeat_count(3); @@ -408,7 +408,7 @@ mod tests { leases.complete_press(lease_key, &key, Some(&context), Some(&context), Some(10)), RepeatPlan::Ignore )); - assert!(leases.is_empty()); + assert_eq!(leases.remove_forwarded(&lease_key), None); } #[test] diff --git a/src/input/mod.rs b/src/input/mod.rs index d563d4fd..0cd6c4bd 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -10,8 +10,6 @@ mod parse; pub use encode::{ encode_cursor_key, encode_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key, }; -#[cfg(test)] -pub(crate) use keybind_help::KeybindHelpGroup; pub(crate) use keybind_help::{ filter_keybind_help_groups, keybind_help_groups, keybind_help_text_char, }; @@ -20,15 +18,15 @@ pub(crate) use keybindings::{ resolve_non_indexed_action, resolve_prefix_binding, KeybindAction, KeybindDispatch, KeybindMatch, }; -pub(crate) use lease::{ - ConsumedInputLease, ForwardedInputLease, InputLeaseKey, InputLeaseTable, RepeatPlan, -}; +pub(crate) use lease::{InputLeaseKey, InputLeaseTable, RepeatPlan}; #[cfg(not(windows))] pub use model::ime_compatible_keyboard_enhancement_flags; #[cfg(any(unix, test))] pub use model::MouseProtocolMode; +#[cfg(any(windows, test))] +pub use model::WindowsKeyRecord; pub use model::{ host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, MouseProtocolEncoding, TerminalKey, - TextCommit, WindowsKeyRecord, + TextCommit, }; pub use parse::parse_terminal_key_sequence; diff --git a/src/input/model.rs b/src/input/model.rs index cada5506..e45ef378 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -3,6 +3,7 @@ use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; +#[cfg(any(windows, test))] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct WindowsKeyRecord { pub key_down: bool, @@ -32,11 +33,13 @@ impl TextCommit { } } +#[cfg(any(windows, test))] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct PhysicalKeyId(u32); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KeyIdentity { + #[cfg(any(windows, test))] Physical(PhysicalKeyId), Semantic(KeyCode), } @@ -47,12 +50,14 @@ pub(crate) enum KeySource { Vt { bytes: Vec, }, + #[cfg(any(windows, test))] WindowsConsole { record: WindowsKeyRecord, physical_key: Option, }, } +#[cfg(any(windows, test))] impl WindowsKeyRecord { fn physical_key_id(self) -> Option { const ENHANCED_KEY: u32 = 0x0100; @@ -73,6 +78,7 @@ pub struct TerminalKey { pub repeat_count: u16, pub shifted_codepoint: Option, pub generated_text: Option, + physical_identity_hint: bool, source: KeySource, } @@ -85,6 +91,7 @@ impl TerminalKey { repeat_count: 1, shifted_codepoint: None, generated_text: None, + physical_identity_hint: false, source: KeySource::Synthesized, } } @@ -112,7 +119,6 @@ impl TerminalKey { self } - #[allow(dead_code)] // Reserved for the upcoming raw input parser to preserve shifted/base key pairs. pub fn with_shifted_codepoint(mut self, shifted_codepoint: u32) -> Self { self.shifted_codepoint = Some(shifted_codepoint); self @@ -132,19 +138,27 @@ impl TerminalKey { self } + #[cfg(any(windows, test))] pub fn with_windows_record(mut self, record: WindowsKeyRecord) -> Self { self.repeat_count = if self.kind == crossterm::event::KeyEventKind::Release { 1 } else { record.repeat_count.max(1) }; + let physical_key = record.physical_key_id(); + self.physical_identity_hint = physical_key.is_some(); self.source = KeySource::WindowsConsole { - physical_key: record.physical_key_id(), + physical_key, record, }; self } + pub(crate) fn with_physical_identity_hint(mut self, physical: bool) -> Self { + self.physical_identity_hint = physical; + self + } + #[cfg(any(windows, test))] pub(crate) fn vt_bytes(&self) -> Option<&[u8]> { match &self.source { @@ -163,26 +177,36 @@ impl TerminalKey { pub(crate) fn identity(&self) -> KeyIdentity { match self.source { + #[cfg(any(windows, test))] KeySource::WindowsConsole { physical_key: Some(physical_key), .. } => KeyIdentity::Physical(physical_key), + #[cfg(any(windows, test))] KeySource::WindowsConsole { physical_key: None, .. - } - | KeySource::Synthesized - | KeySource::Vt { .. } => KeyIdentity::Semantic(self.code), + } => KeyIdentity::Semantic(self.code), + KeySource::Synthesized | KeySource::Vt { .. } => KeyIdentity::Semantic(self.code), } } pub(crate) fn has_physical_identity(&self) -> bool { - matches!( - self.source, + self.physical_identity_hint || self.physical_key_id().is_some() + } + + pub(crate) fn physical_key_id(&self) -> Option { + match &self.source { + #[cfg(any(windows, test))] KeySource::WindowsConsole { - physical_key: Some(_), + physical_key: Some(PhysicalKeyId(id)), .. - } - ) + } => Some(*id), + #[cfg(any(windows, test))] + KeySource::WindowsConsole { + physical_key: None, .. + } => None, + KeySource::Synthesized | KeySource::Vt { .. } => None, + } } pub fn with_text_commit(mut self) -> Self { diff --git a/src/input/mouse.rs b/src/input/mouse.rs index 62b5f6c2..984537d1 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -47,10 +47,12 @@ impl HostGeometry { )) } + #[cfg(test)] fn column_boundary(self, column: u16) -> Option { boundary(column, self.cols, self.width_px) } + #[cfg(test)] fn row_boundary(self, row: u16) -> Option { boundary(row, self.rows, self.height_px) } @@ -63,28 +65,68 @@ impl HostPixels { child_width_px: u32, child_height_px: u32, ) -> Option { - let start_x = self.geometry.column_boundary(inner.x)?; - let start_y = self.geometry.row_boundary(inner.y)?; - let end_x = self - .geometry - .column_boundary(inner.x.checked_add(inner.width)?)?; - let end_y = self - .geometry - .row_boundary(inner.y.checked_add(inner.height)?)?; - let x = self.x.checked_sub(1)?.checked_sub(start_x)?; - let y = self.y.checked_sub(1)?.checked_sub(start_y)?; - let source_width = end_x.checked_sub(start_x)?; - let source_height = end_y.checked_sub(start_y)?; - if x >= source_width || y >= source_height || child_width_px == 0 || child_height_px == 0 { + let (host_column, host_row) = self.geometry.cell(self.x, self.y)?; + let end_column = inner.x.checked_add(inner.width)?; + let end_row = inner.y.checked_add(inner.height)?; + if host_column < inner.x + || host_column >= end_column + || host_row < inner.y + || host_row >= end_row + { return None; } Some(Position::Pixels { - x: scale(x, source_width, child_width_px).checked_add(1)?, - y: scale(y, source_height, child_height_px).checked_add(1)?, + x: map_axis_within_cell( + self.x, + host_column, + inner.x, + inner.width, + self.geometry.cols, + self.geometry.width_px, + child_width_px, + )?, + y: map_axis_within_cell( + self.y, + host_row, + inner.y, + inner.height, + self.geometry.rows, + self.geometry.height_px, + child_height_px, + )?, }) } } +fn map_axis_within_cell( + pixel: u32, + host_cell: u16, + pane_start: u16, + pane_cells: u16, + host_cells: u16, + host_extent: u32, + child_extent: u32, +) -> Option { + let local_cell = host_cell.checked_sub(pane_start)?; + if local_cell >= pane_cells { + return None; + } + let source_start = boundary(host_cell, host_cells, host_extent)?; + let source_end = boundary(host_cell.checked_add(1)?, host_cells, host_extent)?; + let target_start = boundary(local_cell, pane_cells, child_extent)?; + let target_end = boundary(local_cell.checked_add(1)?, pane_cells, child_extent)?; + let source_width = source_end.checked_sub(source_start)?; + let target_width = target_end.checked_sub(target_start)?; + let offset = pixel.checked_sub(1)?.checked_sub(source_start)?; + if source_width == 0 || target_width == 0 || offset >= source_width { + return None; + } + target_start + .checked_add(scale(offset, source_width, target_width))? + .checked_add(1) +} + +#[cfg(any(unix, test))] pub(crate) fn parse_report(data: &[u8]) -> Option<(u32, u32)> { let body = data.strip_prefix(b"\x1b[<")?; let body = body @@ -97,6 +139,7 @@ pub(crate) fn parse_report(data: &[u8]) -> Option<(u32, u32)> { fields.next().is_none().then_some((x, y)) } +#[cfg(any(unix, test))] pub(crate) fn report_at_cell(data: &[u8], column: u16, row: u16) -> Option> { let body = data.strip_prefix(b"\x1b[<")?; let suffix = if body.ends_with(b"M") { 'M' } else { 'm' }; @@ -114,6 +157,7 @@ pub(crate) fn report_at_cell(data: &[u8], column: u16, row: u16) -> Option Option { (!value.is_empty() && value.iter().all(u8::is_ascii_digit)) .then(|| std::str::from_utf8(value).ok()?.parse().ok()) @@ -183,6 +227,21 @@ mod tests { ); } + #[test] + fn fractional_scaling_preserves_the_canonical_child_cell() { + let geometry = HostGeometry::new(80, 1, 805, 20).unwrap(); + assert_eq!(geometry.cell(11, 1), Some((1, 0))); + assert_eq!( + HostPixels { + x: 11, + y: 1, + geometry, + } + .pane_position(ratatui::layout::Rect::new(0, 0, 80, 1), 800, 20), + Some(Position::Pixels { x: 11, y: 1 }) + ); + } + #[test] fn geometry_rejects_outside_pixels_and_maps_cells() { let geometry = HostGeometry::new(80, 24, 800, 480).unwrap(); diff --git a/src/integration/types.rs b/src/integration/types.rs index c7eaf795..2eaf7bf5 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -164,15 +164,6 @@ impl IntegrationRecommendation { self.state == IntegrationStatusKind::Outdated || (self.available && self.state == IntegrationStatusKind::NotInstalled) } - - pub fn status_label(&self) -> &'static str { - match (self.available, self.state) { - (_, IntegrationStatusKind::Current) => "installed", - (_, IntegrationStatusKind::Outdated) => "update available", - (true, IntegrationStatusKind::NotInstalled) => "available", - (false, IntegrationStatusKind::NotInstalled) => "not found", - } - } } #[derive(Debug)] diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index d986b762..1ce90eb3 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -9,7 +9,6 @@ use base64::Engine; use ratatui::layout::Rect; use crate::app::state::AppState; -use crate::app::Mode; use crate::ghostty::{ KittyImageDescriptor, KittyImageFormat, KittyImagePlacement, KittyPlacementRenderInfo, }; @@ -38,12 +37,6 @@ impl HostCellSize { } } -#[derive(Debug, Clone, PartialEq, Eq)] -struct HostViewKey { - workspace_index: usize, - tab_index: usize, -} - #[derive(Debug)] struct HostPlacement { pane_id: PaneId, @@ -118,7 +111,6 @@ pub(crate) struct HostGraphicsCache { sources: HashMap, oversized: HashMap, continuation: Option<(HostSourceKey, u32, usize)>, - view: Option, replay_placements: bool, replayed_placements: HashSet<(u32, u32)>, } @@ -138,302 +130,6 @@ pub(crate) struct EncodedGraphics { pub(crate) incomplete: bool, } -pub(crate) fn encode_local_pane_graphics( - app: &AppState, - graphics: &crate::app::pane_graphics::Runtime, - terminal_runtimes: &TerminalRuntimeRegistry, - surface: crate::ui::TabSurfaceView<'_>, - cell_size: HostCellSize, - transaction_budget: Option, - cache: &mut HostGraphicsCache, -) -> EncodedGraphics { - let visible = app.mode == Mode::Terminal && cell_size.is_known(); - if graphics.slots.is_empty() { - if !visible { - return EncodedGraphics { - bytes: cache.clear_bytes(), - incomplete: false, - }; - } - let mut bytes = if transaction_budget.is_none() && cache.has_pane_sources() { - cache.clear_pane_sources() - } else { - Vec::new() - }; - let placements = collect_visible_placements( - app, - graphics, - terminal_runtimes, - surface, - cell_size, - &cache.images, - &cache.oversized, - ); - let view_changed = cache.update_view(active_view_key(app)); - let mut encoded = - encode_terminal_graphics_update(cache, &placements, view_changed, transaction_budget); - if !bytes.is_empty() { - bytes.extend(encoded.bytes); - encoded.bytes = bytes; - } - return encoded; - } - - let live_pane_sources = graphics - .slots - .iter() - .filter(|(_, slot)| slot.layer.is_some()) - .map(|((pane_id, layer_id), _)| HostSourceKey::PaneLayer { - pane_id: *pane_id, - layer_id: layer_id.clone(), - }) - .collect::>(); - let placements = if visible { - collect_visible_placements( - app, - graphics, - terminal_runtimes, - surface, - cell_size, - &cache.images, - &cache.oversized, - ) - } else { - Vec::new() - }; - cache.update_view(visible.then(|| active_view_key(app)).flatten()); - // The host text blit overwrites Kitty placements, so every rendered frame must - // display cached images again even when their data and geometry are unchanged. - cache.request_placement_replay(); - encode_graphics_update_incremental( - cache, - &placements, - &live_pane_sources, - transaction_budget, - false, - ) -} - -pub(crate) fn has_visible_pane_graphics( - app: &AppState, - graphics: &crate::app::pane_graphics::Runtime, - terminal_runtimes: &TerminalRuntimeRegistry, - surface: crate::ui::TabSurfaceView<'_>, - cell_size: HostCellSize, -) -> bool { - if app.mode != Mode::Terminal || !cell_size.is_known() { - return false; - } - - let Some(ws_idx) = app.active else { - return false; - }; - if app - .workspaces - .get(ws_idx) - .and_then(crate::workspace::Workspace::active_tab) - .is_none() - { - return false; - } - - for info in surface.pane_infos { - let empty_uploaded = HashMap::new(); - if graphics.slots.iter().any(|((pane_id, layer_id), slot)| { - *pane_id == info.id - && slot.layer.as_ref().is_some_and(|layer| { - clipped_placement(&pane_graphics_host_placement( - info, - layer_id, - slot.host_image_id, - cell_size, - layer, - &empty_uploaded, - false, - )) - .is_some() - }) - }) { - return true; - } - - if let Some(runtime) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - { - let scrollback_offset = runtime - .scroll_metrics() - .map(|m| m.offset_from_bottom as u32) - .unwrap_or(0); - for placement in runtime.kitty_image_placements_with_data_filter(|_| false) { - let host_placement = HostPlacement { - pane_id: info.id, - host_image_id: None, - area: info.inner_rect, - cell_size, - source_key: HostSourceKey::Terminal { - pane_id: info.id, - image_id: placement.image_id, - }, - placement, - scrollback_offset, - }; - if clipped_placement(&host_placement).is_some() { - return true; - } - } - } - } - false -} - -fn encode_terminal_graphics_update( - cache: &mut HostGraphicsCache, - placements: &[HostPlacement], - view_changed: bool, - transaction_budget: Option, -) -> EncodedGraphics { - if transaction_budget.is_some() { - cache.request_placement_replay(); - return encode_graphics_update_incremental( - cache, - placements, - &HashSet::new(), - transaction_budget, - true, - ); - } - - cache.reset_incremental_state(); - let mut bytes = Vec::new(); - encode_terminal_graphics_update_legacy(&mut bytes, placements, view_changed, cache); - EncodedGraphics { - bytes, - incomplete: false, - } -} - -fn encode_terminal_graphics_update_legacy( - bytes: &mut Vec, - placements: &[HostPlacement], - view_changed: bool, - cache: &mut HostGraphicsCache, -) { - let current_sources = placements - .iter() - .filter(|placement| matches!(placement.source_key, HostSourceKey::Terminal { .. })) - .map(|placement| placement.source_key.clone()) - .collect::>(); - cache - .sources - .retain(|source, _| current_sources.contains(source)); - - let mut current_placements = HashSet::new(); - for placement in placements { - let Some((clipped, format_code)) = clipped_placement(placement) else { - continue; - }; - let host_id = host_image_id(placement.pane_id, &placement.placement); - let placement_id = host_placement_id(&placement.source_key, &placement.placement); - let image_signature = image_signature(placement, format_code); - let placement_signature = - placement_signature(clipped, placement.placement.z, placement.scrollback_offset); - let placement_key = (host_id, placement_id); - current_placements.insert(placement_key); - - match cache.images.get(&host_id).copied() { - Some(existing) if existing == image_signature => {} - Some(_) => { - encode_delete_image(bytes, host_id); - cache.placements.retain(|(image_id, id), _| { - if *image_id == host_id { - current_placements.remove(&(*image_id, *id)); - false - } else { - true - } - }); - if !encode_upload_image(bytes, placement, format_code, host_id) { - continue; - } - cache.images.insert(host_id, image_signature); - } - None => { - if !encode_upload_image(bytes, placement, format_code, host_id) { - continue; - } - cache.images.insert(host_id, image_signature); - } - } - - release_superseded_terminal_image_legacy( - bytes, - cache, - &mut current_placements, - placement.source_key.clone(), - host_id, - ); - - match cache.placements.get_mut(&placement_key) { - Some(existing) if !view_changed && *existing == placement_signature => {} - Some(existing) => { - encode_display_placement( - bytes, - clipped, - host_id, - placement_id, - placement.placement.z, - ); - *existing = placement_signature; - } - None => { - encode_display_placement( - bytes, - clipped, - host_id, - placement_id, - placement.placement.z, - ); - cache.placements.insert(placement_key, placement_signature); - } - } - } - - let stale = cache - .placements - .keys() - .filter(|key| !current_placements.contains(key)) - .copied() - .collect::>(); - for (host_id, placement_id) in stale { - encode_delete_placement(bytes, host_id, placement_id); - cache.placements.remove(&(host_id, placement_id)); - } -} - -fn release_superseded_terminal_image_legacy( - bytes: &mut Vec, - cache: &mut HostGraphicsCache, - current_placements: &mut HashSet<(u32, u32)>, - source: HostSourceKey, - host_id: u32, -) { - let Some(previous) = cache.sources.insert(source, host_id) else { - return; - }; - if previous == host_id || cache.sources.values().any(|id| *id == previous) { - return; - } - encode_delete_image(bytes, previous); - cache.images.remove(&previous); - cache.placements.retain(|(image_id, placement_id), _| { - if *image_id == previous { - current_placements.remove(&(*image_id, *placement_id)); - false - } else { - true - } - }); -} - /// Whether appending `additional` bytes to the `current_len` bytes already /// assembled keeps the transaction inside the caller's budget. Without a /// budget the incremental path intentionally stays one transaction per call. @@ -448,6 +144,127 @@ fn coalesced_transaction_fits( current_len.saturating_add(additional) <= budget } +fn image_transaction_fits(placement: &HostPlacement, budget: Option) -> bool { + let Some(budget) = budget else { + return true; + }; + image_transfer_estimated_size(placement.placement.data_len) <= budget +} + +pub(crate) fn image_transfer_estimated_size(data_len: usize) -> usize { + let encoded = data_len.div_ceil(3).saturating_mul(4); + let command_overhead = data_len.div_ceil(KITTY_CHUNK_BYTES).saturating_mul(16) + 1024; + encoded.saturating_add(command_overhead) +} + +fn placement_identity(placement: &HostPlacement) -> (HostSourceKey, u32) { + ( + placement.source_key.clone(), + host_placement_id(&placement.source_key, &placement.placement), + ) +} + +fn source_order(source: &HostSourceKey) -> (u32, String) { + match source { + HostSourceKey::Terminal { pane_id, .. } => (pane_id.raw(), String::new()), + HostSourceKey::PaneLayer { pane_id, layer_id } => (pane_id.raw(), layer_id.clone()), + HostSourceKey::ClientSurface { scope, source } => { + let mut hasher = DefaultHasher::new(); + scope.hash(&mut hasher); + source.hash(&mut hasher); + (hasher.finish() as u32, format!("{source:?}")) + } + } +} + +fn encode_placement_update( + cache: &mut HostGraphicsCache, + placement: &HostPlacement, +) -> Option> { + let (clipped, format_code) = clipped_placement(placement)?; + let host_id = placement + .host_image_id + .unwrap_or_else(|| host_image_id(placement.pane_id, &placement.placement)); + let placement_id = host_placement_id(&placement.source_key, &placement.placement); + let key = (host_id, placement_id); + let image_signature = image_signature(placement, format_code); + let placement_signature = + placement_signature(clipped, placement.placement.z, placement.scrollback_offset); + let image_current = cache.images.get(&host_id) == Some(&image_signature); + let placement_current = cache.placements.get(&key) == Some(&placement_signature) + && (!cache.replay_placements || cache.replayed_placements.contains(&key)); + if image_current + && placement_current + && cache.sources.get(&placement.source_key) == Some(&host_id) + { + return None; + } + + let mut bytes = Vec::new(); + let mut displayed = false; + if !image_current { + if cache.images.contains_key(&host_id) + && matches!(placement.source_key, HostSourceKey::PaneLayer { .. }) + { + if !encode_transmit_and_display( + &mut bytes, + placement, + clipped, + format_code, + host_id, + placement_id, + ) { + return None; + } + displayed = true; + } else { + if cache.images.contains_key(&host_id) { + encode_delete_image(&mut bytes, host_id); + cache.placements.retain(|(id, _), _| *id != host_id); + cache.replayed_placements.retain(|(id, _)| *id != host_id); + } + if !encode_upload_image(&mut bytes, placement, format_code, host_id) { + return None; + } + } + cache.images.insert(host_id, image_signature); + } + + release_superseded_source_image(&mut bytes, cache, placement.source_key.clone(), host_id); + if !displayed && !placement_current { + encode_display_placement( + &mut bytes, + clipped, + host_id, + placement_id, + placement.placement.z, + ); + } + cache.placements.insert(key, placement_signature); + if cache.replay_placements { + cache.replayed_placements.insert(key); + } + Some(bytes) +} + +fn release_superseded_source_image( + bytes: &mut Vec, + cache: &mut HostGraphicsCache, + source: HostSourceKey, + host_id: u32, +) { + let Some(previous) = cache.sources.insert(source, host_id) else { + return; + }; + if previous == host_id || cache.sources.values().any(|id| *id == previous) { + return; + } + encode_delete_image(bytes, previous); + cache.images.remove(&previous); + cache.placements.retain(|(id, _), _| *id != previous); + cache.replayed_placements.retain(|(id, _)| *id != previous); +} + fn encode_graphics_update_incremental( cache: &mut HostGraphicsCache, placements: &[HostPlacement], @@ -624,127 +441,6 @@ fn encode_graphics_update_incremental( } } -fn image_transaction_fits(placement: &HostPlacement, budget: Option) -> bool { - let Some(budget) = budget else { - return true; - }; - image_transfer_estimated_size(placement.placement.data_len) <= budget -} - -pub(crate) fn image_transfer_estimated_size(data_len: usize) -> usize { - let encoded = data_len.div_ceil(3).saturating_mul(4); - let command_overhead = data_len.div_ceil(KITTY_CHUNK_BYTES).saturating_mul(16) + 1024; - encoded.saturating_add(command_overhead) -} - -fn placement_identity(placement: &HostPlacement) -> (HostSourceKey, u32) { - ( - placement.source_key.clone(), - host_placement_id(&placement.source_key, &placement.placement), - ) -} - -fn source_order(source: &HostSourceKey) -> (u32, String) { - match source { - HostSourceKey::Terminal { pane_id, .. } => (pane_id.raw(), String::new()), - HostSourceKey::PaneLayer { pane_id, layer_id } => (pane_id.raw(), layer_id.clone()), - HostSourceKey::ClientSurface { scope, source } => { - let mut hasher = DefaultHasher::new(); - scope.hash(&mut hasher); - source.hash(&mut hasher); - (hasher.finish() as u32, format!("{source:?}")) - } - } -} - -fn encode_placement_update( - cache: &mut HostGraphicsCache, - placement: &HostPlacement, -) -> Option> { - let (clipped, format_code) = clipped_placement(placement)?; - let host_id = placement - .host_image_id - .unwrap_or_else(|| host_image_id(placement.pane_id, &placement.placement)); - let placement_id = host_placement_id(&placement.source_key, &placement.placement); - let key = (host_id, placement_id); - let image_signature = image_signature(placement, format_code); - let placement_signature = - placement_signature(clipped, placement.placement.z, placement.scrollback_offset); - let image_current = cache.images.get(&host_id) == Some(&image_signature); - let placement_current = cache.placements.get(&key) == Some(&placement_signature) - && (!cache.replay_placements || cache.replayed_placements.contains(&key)); - if image_current - && placement_current - && cache.sources.get(&placement.source_key) == Some(&host_id) - { - return None; - } - - let mut bytes = Vec::new(); - let mut displayed = false; - if !image_current { - if cache.images.contains_key(&host_id) - && matches!(placement.source_key, HostSourceKey::PaneLayer { .. }) - { - if !encode_transmit_and_display( - &mut bytes, - placement, - clipped, - format_code, - host_id, - placement_id, - ) { - return None; - } - displayed = true; - } else { - if cache.images.contains_key(&host_id) { - encode_delete_image(&mut bytes, host_id); - cache.placements.retain(|(id, _), _| *id != host_id); - cache.replayed_placements.retain(|(id, _)| *id != host_id); - } - if !encode_upload_image(&mut bytes, placement, format_code, host_id) { - return None; - } - } - cache.images.insert(host_id, image_signature); - } - - release_superseded_source_image(&mut bytes, cache, placement.source_key.clone(), host_id); - if !displayed && !placement_current { - encode_display_placement( - &mut bytes, - clipped, - host_id, - placement_id, - placement.placement.z, - ); - } - cache.placements.insert(key, placement_signature); - if cache.replay_placements { - cache.replayed_placements.insert(key); - } - Some(bytes) -} - -fn release_superseded_source_image( - bytes: &mut Vec, - cache: &mut HostGraphicsCache, - source: HostSourceKey, - host_id: u32, -) { - let Some(previous) = cache.sources.insert(source, host_id) else { - return; - }; - if previous == host_id || cache.sources.values().any(|id| *id == previous) { - return; - } - encode_delete_image(bytes, previous); - cache.images.remove(&previous); - cache.placements.retain(|(id, _), _| *id != previous); - cache.replayed_placements.retain(|(id, _)| *id != previous); -} - #[cfg(test)] fn drain_graphics_updates( cache: &mut HostGraphicsCache, @@ -761,93 +457,13 @@ fn drain_graphics_updates( } } -#[cfg(test)] -fn encode_graphics_update( - bytes: &mut Vec, - placements: &[HostPlacement], - replay: bool, - images: &mut HashMap, - host_placements: &mut HashMap<(u32, u32), PlacementSignature>, - sources: &mut HashMap, -) { - let mut cache = HostGraphicsCache { - images: std::mem::take(images), - placements: std::mem::take(host_placements), - sources: std::mem::take(sources), - ..HostGraphicsCache::default() - }; - let mut live = cache - .sources - .keys() - .filter(|source| matches!(source, HostSourceKey::PaneLayer { .. })) - .cloned() - .collect::>(); - live.extend( - placements - .iter() - .filter(|placement| matches!(placement.source_key, HostSourceKey::PaneLayer { .. })) - .map(|placement| placement.source_key.clone()), - ); - if live.is_empty() { - encode_terminal_graphics_update_legacy(bytes, placements, replay, &mut cache); - } else { - if replay { - cache.request_placement_replay(); - } - bytes.extend(drain_graphics_updates(&mut cache, placements, &live)); - } - *images = cache.images; - *host_placements = cache.placements; - *sources = cache.sources; -} - impl HostGraphicsCache { - fn clear_pane_sources(&mut self) -> Vec { - let pane_sources = self - .sources - .keys() - .filter(|source| matches!(source, HostSourceKey::PaneLayer { .. })) - .cloned() - .collect::>(); - let mut removed_images = HashSet::new(); - for source in pane_sources { - if let Some(image_id) = self.sources.remove(&source) { - removed_images.insert(image_id); - } - self.oversized.remove(&source); - } - - let mut bytes = Vec::new(); - for image_id in removed_images { - if self.sources.values().any(|id| *id == image_id) { - continue; - } - encode_delete_image(&mut bytes, image_id); - self.images.remove(&image_id); - self.placements.retain(|(id, _), _| *id != image_id); - self.replayed_placements.retain(|(id, _)| *id != image_id); - } - self.reset_incremental_progress(); - bytes - } - - fn has_pane_sources(&self) -> bool { - self.sources - .keys() - .any(|source| matches!(source, HostSourceKey::PaneLayer { .. })) - } - fn reset_incremental_progress(&mut self) { self.continuation = None; self.replay_placements = false; self.replayed_placements.clear(); } - fn reset_incremental_state(&mut self) { - self.oversized.clear(); - self.reset_incremental_progress(); - } - fn quarantine_oversized(&mut self, source: HostSourceKey, signature: ImageSignature) { if !self.oversized.contains_key(&source) && self.oversized.len() >= MAX_OVERSIZED_SOURCES { if let Some(evicted) = self.oversized.keys().next().cloned() { @@ -857,38 +473,6 @@ impl HostGraphicsCache { self.oversized.insert(source, signature); } - pub(crate) fn trust_pane_layer( - &mut self, - key: &crate::app::pane_graphics::Key, - host_id: u32, - layer: &crate::app::pane_graphics::Layer, - ) { - let source = HostSourceKey::PaneLayer { - pane_id: key.0, - layer_id: key.1.clone(), - }; - self.oversized.remove(&source); - self.sources.insert(source, host_id); - self.images - .insert(host_id, pane_layer_image_signature(layer)); - } - - pub(crate) fn forget_pane_layer(&mut self, key: &crate::app::pane_graphics::Key, host_id: u32) { - let source = HostSourceKey::PaneLayer { - pane_id: key.0, - layer_id: key.1.clone(), - }; - self.sources.remove(&source); - self.oversized.remove(&source); - self.images.remove(&host_id); - self.placements.retain(|(id, _), _| *id != host_id); - self.replayed_placements.retain(|(id, _)| *id != host_id); - } - - pub(crate) fn is_empty(&self) -> bool { - self.images.is_empty() && self.placements.is_empty() - } - pub(crate) fn request_placement_replay(&mut self) { if !self.replay_placements { self.replay_placements = true; @@ -896,30 +480,6 @@ impl HostGraphicsCache { } } - #[cfg(test)] - fn hide_except_live_pane_layers(&mut self, live: &HashSet) -> Vec { - drain_graphics_updates(self, &[], live) - } - - #[cfg(test)] - pub(crate) fn test_image_count(&self) -> usize { - self.images.len() - } - - #[cfg(test)] - pub(crate) fn test_mark_non_empty(&mut self) { - self.images.insert( - HOST_IMAGE_ID_BASE, - ImageSignature { - image_width: 1, - image_height: 1, - format_code: 32, - data_len: 4, - data_fingerprint: 1, - }, - ); - } - pub(crate) fn clear_bytes(&mut self) -> Vec { let mut bytes = Vec::new(); for id in self.images.keys().copied().collect::>() { @@ -928,54 +488,10 @@ impl HostGraphicsCache { self.images.clear(); self.placements.clear(); self.sources.clear(); - self.reset_incremental_state(); - self.view = None; + self.oversized.clear(); + self.reset_incremental_progress(); bytes } - - pub(crate) fn clear_next(&mut self) -> EncodedGraphics { - self.continuation = None; - let mut bytes = Vec::new(); - if let Some(id) = self.images.keys().copied().min() { - encode_delete_image(&mut bytes, id); - self.images.remove(&id); - self.placements.retain(|(image, _), _| *image != id); - self.sources.retain(|_, image| *image != id); - self.replayed_placements.retain(|(image, _)| *image != id); - } else if let Some(key) = self.placements.keys().copied().min() { - encode_delete_placement(&mut bytes, key.0, key.1); - self.placements.remove(&key); - self.replayed_placements.remove(&key); - } else { - self.sources.clear(); - self.oversized.clear(); - self.view = None; - self.replay_placements = false; - self.replayed_placements.clear(); - } - EncodedGraphics { - bytes, - incomplete: !self.is_empty(), - } - } - - fn update_view(&mut self, view_key: Option) -> bool { - if self.view == view_key { - return false; - } - self.view = view_key; - self.continuation = None; - true - } -} - -fn active_view_key(app: &AppState) -> Option { - let ws_idx = app.active?; - let ws = app.workspaces.get(ws_idx)?; - Some(HostViewKey { - workspace_index: ws_idx, - tab_index: ws.active_tab_index(), - }) } fn collect_visible_placements( @@ -1220,86 +736,6 @@ pub(crate) struct DirectFileCommand { pub(crate) control: String, } -pub(crate) fn prepare_direct_file( - app: &AppState, - graphics: &crate::app::pane_graphics::Runtime, - surface: crate::ui::TabSurfaceView<'_>, - cell_size: HostCellSize, - allow_placement: bool, - cache: &HostGraphicsCache, - key: &crate::app::pane_graphics::Key, -) -> Option { - let slot = graphics.slots.get(key)?; - let layer = slot.layer.as_ref()?; - layer.direct_lease()?; - - let info = allow_placement - .then(|| surface.pane_infos.iter().find(|info| info.id == key.0)) - .flatten() - .filter(|_| app.mode == Mode::Terminal && cell_size.is_known() && app.active.is_some()); - if let Some(command) = info - .map(|info| { - pane_graphics_host_placement( - info, - &key.1, - slot.host_image_id, - cell_size, - layer, - &cache.images, - false, - ) - }) - .and_then(|placement| direct_file_command(&placement, slot.host_image_id)) - .map(|(command, _, _, _)| command) - { - return Some(command); - } - - let inline_fallback_available = layer.data_len() - <= crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES - && graphics.can_store_inline(key, layer.data_len()); - (!inline_fallback_available).then(|| direct_file_upload_command(layer, slot.host_image_id)) -} - -fn direct_file_upload_command( - layer: &crate::app::pane_graphics::Layer, - host_image_id: u32, -) -> DirectFileCommand { - DirectFileCommand { - leading: Vec::new(), - control: format!( - "a=t,f=32,s={},v={},i={host_image_id},q=0", - layer.image_width, layer.image_height - ), - } -} - -fn direct_file_command( - placement: &HostPlacement, - host_image_id: u32, -) -> Option<(DirectFileCommand, ClippedPlacement, u32, u32)> { - let (clipped, format_code) = clipped_placement(placement)?; - let placement_id = host_placement_id(&placement.source_key, &placement.placement); - let mut control = format!( - "a=T,f={format_code},s={},v={},i={host_image_id},p={placement_id},c={},r={},z={},C=1,q=0", - placement.placement.image_width, - placement.placement.image_height, - clipped.cols, - clipped.rows, - placement.placement.z, - ); - append_placement_controls(&mut control, clipped); - Some(( - DirectFileCommand { - leading: format!("\x1b[{};{}H", clipped.y + 1, clipped.x + 1).into_bytes(), - control, - }, - clipped, - format_code, - placement_id, - )) -} - #[cfg(unix)] pub(crate) fn encode_kitty_regular_file( out: &mut Vec, @@ -1692,59 +1128,19 @@ mod tests { replay: bool, ) -> Vec { let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - placements, - replay, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); + if replay { + cache.request_placement_replay(); + } + let live = cache + .sources + .keys() + .filter(|source| matches!(source, HostSourceKey::PaneLayer { .. })) + .cloned() + .collect::>(); + bytes.extend(drain_graphics_updates(cache, placements, &live)); bytes } - #[test] - fn terminal_graphics_without_pane_layers_preserves_legacy_transcript() { - fn record(transcript: &mut Vec, bytes: &[u8]) { - transcript.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); - transcript.extend_from_slice(bytes); - } - - fn fnv1a(bytes: &[u8]) -> u64 { - bytes.iter().fold(0xcbf29ce484222325, |hash, byte| { - (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) - }) - } - - let mut cache = HostGraphicsCache::default(); - let mut transcript = Vec::new(); - - record( - &mut transcript, - &update(&mut cache, &[test_placement(0, 0)], false), - ); - record( - &mut transcript, - &update(&mut cache, &[test_placement(0, 0)], false), - ); - record( - &mut transcript, - &update(&mut cache, &[test_placement(-1, 2)], false), - ); - record( - &mut transcript, - &update(&mut cache, &[test_placement(-1, 2)], true), - ); - - let mut changed = test_placement(-1, 2); - changed.placement.data_fingerprint = 43; - record(&mut transcript, &update(&mut cache, &[changed], false)); - record(&mut transcript, &update(&mut cache, &[], false)); - - assert_eq!(transcript.len(), 10_084); - assert_eq!(fnv1a(&transcript), 0xc5bd_83e4_b039_870e); - } - #[test] fn terminal_placement_id_preserves_legacy_identity() { let placement = test_placement(0, 0); @@ -1786,19 +1182,6 @@ mod tests { assert!(text.ends_with("\x1b\\\x1b8")); } - #[test] - fn direct_file_uses_one_clipped_transmit_and_display_at_the_final_position() { - let mut placement = pane_layer_placement(-1, 2); - placement.area = Rect::new(10, 4, 8, 6); - let (command, _, _, _) = direct_file_command(&placement, (1 << 31) | 9).unwrap(); - let control = command.control; - - assert_eq!(command.leading, b"\x1b[7;11H"); - assert!(control.starts_with("a=T,f=32,s=30,v=30,i=2147483657,p=")); - assert!(control.contains(",c=2,r=3,z=0,C=1,q=0,x=10,w=20,h=30")); - assert!(direct_file_command(&pane_layer_placement(30, 0), 9).is_none()); - } - #[test] fn pane_graphics_image_ids_are_disjoint_from_terminal_image_ids() { let placement = test_placement(0, 0); @@ -1946,530 +1329,6 @@ mod tests { assert!(String::from_utf8_lossy(&bytes).contains("a=p")); } - #[test] - fn empty_image_data_does_not_mark_image_uploaded() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let mut placement = test_placement(0, 0); - placement.placement.data.clear(); - - encode_graphics_update( - &mut bytes, - &[placement], - false, - &mut images, - &mut placements, - &mut sources, - ); - - assert!(bytes.is_empty()); - assert!(images.is_empty()); - assert!(placements.is_empty()); - } - - #[test] - fn same_image_signature_reuses_host_upload_across_source_image_ids() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let first = test_placement(0, 0); - - encode_graphics_update( - &mut bytes, - &[first], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1); - assert_eq!(placements.len(), 1); - - bytes.clear(); - let mut same_image_new_source_id = test_placement(0, 0); - same_image_new_source_id.placement.image_id = 8; - same_image_new_source_id.placement.placement_id = 4; - same_image_new_source_id.placement.data.clear(); - encode_graphics_update( - &mut bytes, - &[same_image_new_source_id], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let reused = String::from_utf8_lossy(&bytes); - assert!(!reused.contains("a=t")); - assert!(reused.contains("a=p")); - assert_eq!(images.len(), 1); - assert_eq!(placements.len(), 1); - } - - #[test] - fn pane_layer_replacement_is_atomic_without_delete_to_blank() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let mut first = pane_layer_placement(0, 0); - first.host_image_id = Some((1 << 31) | 7); - encode_graphics_update( - &mut bytes, - &[first], - false, - &mut images, - &mut placements, - &mut sources, - ); - - bytes.clear(); - let mut changed = pane_layer_placement(0, 0); - changed.host_image_id = Some((1 << 31) | 7); - changed.placement.data_fingerprint += 1; - encode_graphics_update( - &mut bytes, - &[changed], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains("a=T,t=d")); - assert!(update.contains(",p=") && update.contains(",C=1,q=2")); - assert!(!update.contains("a=d")); - } - - #[test] - fn replaced_image_content_deletes_superseded_host_image() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let first = test_placement(0, 0); - - encode_graphics_update( - &mut bytes, - &[first], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1); - let superseded_host_id = *images.keys().next().expect("uploaded host image"); - - // Same source image id, new pixel content: the fresh content maps to - // a fresh host image id, so the replaced one must be deleted. - bytes.clear(); - let mut changed = test_placement(0, 0); - changed.placement.data_fingerprint = 43; - encode_graphics_update( - &mut bytes, - &[changed], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains("a=t"), "changed content re-uploads"); - assert!( - update.contains(&format!("a=d,d=I,i={superseded_host_id}")), - "superseded host image is deleted" - ); - assert_eq!(images.len(), 1); - assert_eq!(placements.len(), 1); - } - - #[test] - fn shared_host_image_survives_while_another_source_references_it() { - fn twin_placement() -> HostPlacement { - let mut twin = test_placement(5, 5); - twin.placement.image_id = 8; - twin.placement.placement_id = 4; - twin.source_key = HostSourceKey::Terminal { - pane_id: twin.pane_id, - image_id: twin.placement.image_id, - }; - twin - } - - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - - encode_graphics_update( - &mut bytes, - &[test_placement(0, 0), twin_placement()], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1, "same content dedups to one host image"); - - // One source moves to new content while the other still shows the - // old image: the shared host image must survive. - bytes.clear(); - let mut changed = test_placement(0, 0); - changed.placement.data_fingerprint = 43; - encode_graphics_update( - &mut bytes, - &[changed, twin_placement()], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(!update.contains("a=d,d=I"), "shared host image survives"); - assert_eq!(images.len(), 2); - } - - #[test] - fn stale_source_entry_does_not_block_superseded_image_delete() { - fn twin_placement() -> HostPlacement { - let mut twin = test_placement(5, 5); - twin.placement.image_id = 8; - twin.placement.placement_id = 4; - twin.source_key = HostSourceKey::Terminal { - pane_id: twin.pane_id, - image_id: twin.placement.image_id, - }; - twin - } - - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - - encode_graphics_update( - &mut bytes, - &[test_placement(0, 0), twin_placement()], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 2); - let shared_host_id = *images.keys().next().expect("uploaded host image"); - - // The twin source is gone and the survivor changed content: the - // vanished source's stale entry must not keep the old host image - // alive. - bytes.clear(); - let mut changed = test_placement(0, 0); - changed.placement.data_fingerprint = 43; - encode_graphics_update( - &mut bytes, - &[changed], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!( - update.contains(&format!("a=d,d=I,i={shared_host_id}")), - "old host image is deleted once its last live source moves on" - ); - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 1); - } - - #[test] - fn stale_placement_deletes_placement_not_image_immediately() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let placement = test_placement(0, 0); - - encode_graphics_update( - &mut bytes, - &[placement], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(placements.len(), 1); - - bytes.clear(); - encode_graphics_update( - &mut bytes, - &[], - false, - &mut images, - &mut placements, - &mut sources, - ); - let delete = String::from_utf8_lossy(&bytes); - assert!(delete.contains("a=d,d=i")); - assert!(!delete.contains("d=I")); - assert!(placements.is_empty()); - assert_eq!(images.len(), 1); - } - - #[test] - fn trusted_direct_image_uses_reserved_id_for_placement_without_upload() { - let key = (PaneId::from_raw(1), "primary".to_owned()); - let layer = crate::app::pane_graphics::Layer::inline( - crate::api::schema::PaneGraphicsFormat::Rgba, - 30, - 30, - vec![255; 30 * 30 * 4], - Default::default(), - 0, - ); - let reserved_id = (1 << 31) | 77; - let mut cache = HostGraphicsCache::default(); - cache.trust_pane_layer(&key, reserved_id, &layer); - let mut placement = pane_layer_placement(0, 0); - placement.host_image_id = Some(reserved_id); - placement.placement.data.clear(); - placement.placement.data_len = layer.data_len(); - placement.placement.data_fingerprint = layer.data_fingerprint; - let mut bytes = Vec::new(); - - encode_graphics_update( - &mut bytes, - &[placement], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains(&format!("a=p,i={reserved_id}"))); - assert!(!update.contains("a=t")); - - let live = HashSet::from([HostSourceKey::PaneLayer { - pane_id: key.0, - layer_id: key.1.clone(), - }]); - let hidden = String::from_utf8(cache.hide_except_live_pane_layers(&live)).unwrap(); - assert!(hidden.contains("a=d,d=i")); - assert!(!hidden.contains("a=d,d=I")); - assert!(cache.images.contains_key(&reserved_id)); - assert!(cache.placements.is_empty()); - - let mut returning = pane_layer_placement(0, 0); - returning.host_image_id = Some(reserved_id); - returning.placement.data.clear(); - returning.placement.data_len = layer.data_len(); - returning.placement.data_fingerprint = layer.data_fingerprint; - let mut replay = Vec::new(); - encode_graphics_update( - &mut replay, - &[returning], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - let replay = String::from_utf8(replay).unwrap(); - assert!(replay.contains(&format!("a=p,i={reserved_id}"))); - assert!(!replay.contains("a=t")); - - cache.forget_pane_layer(&key, reserved_id); - let mut fallback = pane_layer_placement(0, 0); - fallback.host_image_id = Some(reserved_id); - let mut retransmit = Vec::new(); - encode_graphics_update( - &mut retransmit, - &[fallback], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - assert!(String::from_utf8_lossy(&retransmit).contains("a=t")); - } - - #[test] - fn hidden_layer_and_full_redraw_replay_placement_without_pixels() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let placement = pane_layer_placement(0, 0); - encode_graphics_update( - &mut bytes, - &[placement], - false, - &mut images, - &mut placements, - &mut sources, - ); - - for (visible, replay) in [(false, false), (true, false), (true, true)] { - bytes.clear(); - let current = visible.then(|| pane_layer_placement(0, 0)); - encode_graphics_update( - &mut bytes, - current.as_slice(), - replay, - &mut images, - &mut placements, - &mut sources, - ); - let update = String::from_utf8_lossy(&bytes); - assert!(!update.contains("a=t")); - assert!(!update.contains("a=d,d=I")); - assert_eq!(update.contains("a=p"), visible); - } - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 1); - } - - #[test] - fn removed_pane_layer_deletes_unreferenced_host_image() { - let mut cache = HostGraphicsCache::default(); - let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - &[pane_layer_placement(0, 0)], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - let host_id = *cache.images.keys().next().expect("uploaded pane layer"); - - bytes = drain_graphics_updates(&mut cache, &[], &HashSet::new()); - - let delete = String::from_utf8_lossy(&bytes); - assert!(delete.contains(&format!("a=d,d=I,i={host_id}"))); - assert!(cache.images.is_empty()); - assert!(cache.placements.is_empty()); - assert!(cache.sources.is_empty()); - } - - #[test] - fn hidden_pane_layer_retains_image_and_removes_only_placement() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - &[pane_layer_placement(0, 0)], - false, - &mut images, - &mut placements, - &mut sources, - ); - let host_id = *images.keys().next().expect("uploaded pane layer"); - - bytes.clear(); - encode_graphics_update( - &mut bytes, - &[pane_layer_placement(100, 100)], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains("a=d,d=i")); - assert!(!update.contains(&format!("a=d,d=I,i={host_id}"))); - assert_eq!(images.len(), 1); - assert!(placements.is_empty()); - assert_eq!(sources.len(), 1); - } - - #[test] - fn clipped_terminal_source_retains_identity_for_later_content_replacement() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - &[test_placement(0, 0)], - false, - &mut images, - &mut placements, - &mut sources, - ); - let original_host_id = *images.keys().next().expect("uploaded terminal image"); - - bytes.clear(); - encode_graphics_update( - &mut bytes, - &[test_placement(100, 100)], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 1); - - bytes.clear(); - let mut changed = test_placement(0, 0); - changed.placement.data_fingerprint = 43; - encode_graphics_update( - &mut bytes, - &[changed], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains(&format!("a=d,d=I,i={original_host_id}"))); - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 1); - } - - #[test] - fn removed_pane_layer_preserves_image_shared_with_terminal_source() { - let mut cache = HostGraphicsCache::default(); - let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - &[pane_layer_placement(0, 0), test_placement(4, 0)], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - assert_eq!(cache.images.len(), 1); - - bytes = drain_graphics_updates(&mut cache, &[], &HashSet::new()); - encode_graphics_update( - &mut bytes, - &[test_placement(4, 0)], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(!update.contains("a=d,d=I")); - assert_eq!(cache.images.len(), 1); - assert_eq!(cache.placements.len(), 1); - assert_eq!(cache.sources.len(), 1); - } - #[test] fn changing_first_source_does_not_starve_second_source() { let layers = |first| { @@ -2560,482 +1419,6 @@ mod tests { } } - #[test] - fn terminal_only_headless_budget_does_not_let_large_image_starve_small_image() { - let mut large = test_placement(0, 0); - large.placement.data_len = 24 * 1024 * 1024; - let mut small = test_placement(4, 0); - small.placement.image_id = 8; - small.source_key = HostSourceKey::Terminal { - pane_id: small.pane_id, - image_id: 8, - }; - let small_source = small.source_key.clone(); - let mut cache = HostGraphicsCache::default(); - - let large_only = encode_terminal_graphics_update( - &mut cache, - std::slice::from_ref(&large), - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(large_only.bytes.is_empty()); - assert_eq!(cache.oversized.len(), 1); - assert!(cache.images.is_empty()); - - let hidden = encode_terminal_graphics_update( - &mut cache, - &[], - true, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(hidden.bytes.is_empty()); - assert_eq!(cache.oversized.len(), 1); - - let with_small = encode_terminal_graphics_update( - &mut cache, - &[large, small], - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(String::from_utf8_lossy(&with_small.bytes).contains("a=t")); - assert!(with_small.bytes.len() <= HEADLESS_GRAPHICS_TRANSACTION_BUDGET); - assert!(!with_small.incomplete); - assert_eq!(cache.oversized.len(), 1); - assert_eq!(cache.images.len(), 1); - assert!(cache.sources.contains_key(&small_source)); - } - - /// A Unicode-placeholder image reaches `encode_terminal_graphics_update` as one - /// placement per viewport row, because `kitty_virtual_image_placements` scans the - /// viewport row by row. Build one image that covers `rows` rows that way. - fn image_covering_rows(rows: usize) -> Vec { - let cell_height = 10u32; - let image_height = (rows as u32) * cell_height; - let image_width = 30u32; - let data_len = (image_width * image_height * 4) as usize; - (0..rows) - .map(|row| { - let mut placement = test_placement(0, row as i32); - placement.area = Rect::new(0, 0, 120, 60); - placement.placement.placement_id = 100 + row as u32; - placement.placement.image_width = image_width; - placement.placement.image_height = image_height; - placement.placement.data_len = data_len; - placement.placement.data = vec![255; data_len]; - placement.placement.render.grid_rows = 1; - placement.placement.render.source_y = (row as u32) * cell_height; - placement.placement.render.source_height = cell_height; - placement.placement.render.source_width = image_width; - placement - }) - .collect() - } - - #[test] - fn budgeted_image_rows_upload_in_one_transaction() { - const IMAGE_ROWS: usize = 23; - let placements = image_covering_rows(IMAGE_ROWS); - let mut cache = HostGraphicsCache::default(); - - let upload = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!upload.incomplete); - let upload = String::from_utf8(upload.bytes).unwrap(); - assert_eq!(upload.matches("a=t").count(), 1); - assert_eq!(upload.matches("a=p").count(), IMAGE_ROWS); - assert_eq!(cache.images.len(), 1, "one image backs every row"); - assert_eq!(cache.placements.len(), IMAGE_ROWS, "one placement per row"); - } - - #[test] - fn budgeted_image_rows_disappear_in_one_transaction() { - const IMAGE_ROWS: usize = 23; - let placements = image_covering_rows(IMAGE_ROWS); - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - let removed = encode_terminal_graphics_update( - &mut cache, - &[], - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!removed.incomplete); - let removed = String::from_utf8(removed.bytes).unwrap(); - assert_eq!(removed.matches("a=d,d=i").count(), IMAGE_ROWS); - assert!(cache.placements.is_empty()); - } - - #[test] - fn budgeted_image_rows_delete_old_rows_together_before_replacement() { - const IMAGE_ROWS: usize = 23; - let old = image_covering_rows(IMAGE_ROWS); - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &old, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - let mut replacement = image_covering_rows(IMAGE_ROWS); - for placement in &mut replacement { - placement.placement.data_fingerprint += 1; - } - let cleanup = encode_terminal_graphics_update( - &mut cache, - &replacement, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(cleanup.incomplete); - let cleanup = String::from_utf8(cleanup.bytes).unwrap(); - assert_eq!(cleanup.matches("a=d,d=i").count(), IMAGE_ROWS); - assert!(!cleanup.contains("a=t")); - - let replaced = encode_terminal_graphics_update( - &mut cache, - &replacement, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!replaced.incomplete); - let replaced = String::from_utf8(replaced.bytes).unwrap(); - assert_eq!(replaced.matches("a=t").count(), 1); - assert_eq!(replaced.matches("a=d,d=I").count(), 1); - assert_eq!(replaced.matches("a=p").count(), IMAGE_ROWS); - assert_eq!(cache.images.len(), 1); - assert_eq!(cache.placements.len(), IMAGE_ROWS); - } - - #[test] - fn budgeted_image_rows_redisplay_in_one_transaction() { - const IMAGE_ROWS: usize = 23; - let placements = image_covering_rows(IMAGE_ROWS); - let mut cache = HostGraphicsCache::default(); - - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - let replay = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!replay.incomplete); - let replay = String::from_utf8(replay.bytes).unwrap(); - assert!(!replay.contains("a=t")); - assert_eq!(replay.matches("a=p").count(), IMAGE_ROWS); - } - - #[test] - fn budgeted_pixel_uploads_do_not_coalesce() { - let first = test_placement(0, 0); - let mut second = test_placement(4, 0); - second.placement.image_id = 8; - second.placement.placement_id = 4; - second.placement.data_fingerprint = 43; - second.source_key = HostSourceKey::Terminal { - pane_id: second.pane_id, - image_id: 8, - }; - let placements = [first, second]; - let mut cache = HostGraphicsCache::default(); - - let first_pass = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!( - first_pass.incomplete, - "the second upload must wait for its own transaction" - ); - let first_pass = String::from_utf8(first_pass.bytes).unwrap(); - assert_eq!(first_pass.matches("a=t").count(), 1); - - let second_pass = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!second_pass.incomplete); - let second_pass = String::from_utf8(second_pass.bytes).unwrap(); - assert_eq!(second_pass.matches("a=t").count(), 1); - assert_eq!(cache.images.len(), 2); - - let replay = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!replay.incomplete); - let replay = String::from_utf8(replay.bytes).unwrap(); - assert!(!replay.contains("a=t")); - assert_eq!(replay.matches("a=p").count(), 2); - } - - #[test] - fn budgeted_redisplay_coalescing_respects_budget() { - let placements = image_covering_rows(2); - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - // A budget that admits one re-display command but not two. - let tight = encode_terminal_graphics_update(&mut cache, &placements, false, Some(100)); - assert!(tight.incomplete); - let tight = String::from_utf8(tight.bytes).unwrap(); - assert!(!tight.contains("a=t")); - assert_eq!(tight.matches("a=p").count(), 1); - - let rest = encode_terminal_graphics_update(&mut cache, &placements, false, Some(100)); - assert!(!rest.incomplete); - let rest = String::from_utf8(rest.bytes).unwrap(); - assert!(!rest.contains("a=t")); - assert_eq!(rest.matches("a=p").count(), 1); - } - - #[test] - fn budgeted_upload_keeps_other_redisplays_out() { - let fresh = test_placement(0, 0); - let mut cached = test_placement(4, 0); - cached.placement.image_id = 8; - cached.placement.placement_id = 4; - cached.placement.data_fingerprint = 43; - cached.source_key = HostSourceKey::Terminal { - pane_id: cached.pane_id, - image_id: 8, - }; - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - std::slice::from_ref(&cached), - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - // A fresh image followed by the cached image at a new position. - cached.placement.render.viewport_col = 8; - let placements = [fresh, cached]; - let mut passes = 0; - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - passes += 1; - assert!(passes <= 4, "did not converge"); - let bytes = String::from_utf8(encoded.bytes).unwrap(); - if bytes.contains("a=t") { - assert_eq!( - bytes.matches("a=p").count(), - 1, - "an upload carries only its own placement" - ); - } - if !encoded.incomplete { - break; - } - } - assert_eq!(cache.images.len(), 2); - } - - #[test] - fn budgeted_superseded_image_delete_does_not_coalesce() { - fn pair() -> [HostPlacement; 2] { - let first = test_placement(0, 0); - let mut second = test_placement(4, 0); - second.placement.image_id = 8; - second.placement.placement_id = 4; - second.placement.data_fingerprint = 43; - second.source_key = HostSourceKey::Terminal { - pane_id: second.pane_id, - image_id: 8, - }; - [first, second] - } - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &pair(), - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - assert_eq!(cache.images.len(), 2); - - // The first source now shows the second image's content, so its old - // image gets released, while the second placement moves. - let [mut first, mut second] = pair(); - first.placement.data_fingerprint = 43; - second.placement.render.viewport_col = 8; - let placements = [first, second]; - let mut passes = 0; - let mut saw_release = false; - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - passes += 1; - assert!(passes <= 6, "did not converge"); - let bytes = String::from_utf8(encoded.bytes).unwrap(); - if bytes.contains("a=d,d=I") { - saw_release = true; - assert!( - bytes.matches("a=p").count() <= 1, - "a superseded-image delete carries at most its own placement" - ); - } - if !encoded.incomplete { - break; - } - } - assert!(saw_release, "the old image was released"); - assert_eq!(cache.images.len(), 1); - } - - #[test] - fn budgeted_pane_cleanup_precedes_terminal_image_upload() { - let mut cache = HostGraphicsCache::default(); - let pane_source = HostSourceKey::PaneLayer { - pane_id: PaneId::from_raw(1), - layer_id: "primary".into(), - }; - cache.sources.insert(pane_source, 99); - cache.images.insert( - 99, - ImageSignature { - image_width: 30, - image_height: 30, - format_code: 32, - data_len: 30 * 30 * 4, - data_fingerprint: 9, - }, - ); - let terminal = test_placement(0, 0); - - let cleanup = encode_terminal_graphics_update( - &mut cache, - std::slice::from_ref(&terminal), - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(cleanup.incomplete); - let cleanup = String::from_utf8(cleanup.bytes).unwrap(); - assert!(cleanup.contains("a=d,d=I,i=99")); - assert!(!cleanup.contains("a=t")); - assert!(cache.images.is_empty()); - - let upload = encode_terminal_graphics_update( - &mut cache, - &[terminal], - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(String::from_utf8_lossy(&upload.bytes).contains("a=t")); - } - - #[test] - fn terminal_only_high_level_path_preserves_budget_quarantine() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = crate::workspace::Workspace::test_new("graphics-budget-dispatch"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - crate::ui::compute_view(&mut app, Rect::new(0, 0, 80, 24)); - - let source = HostSourceKey::Terminal { - pane_id, - image_id: 7, - }; - let mut cache = HostGraphicsCache::default(); - cache.quarantine_oversized( - source.clone(), - ImageSignature { - image_width: 3456, - image_height: 2234, - format_code: 32, - data_len: 3456 * 2234 * 4, - data_fingerprint: 42, - }, - ); - - let encoded = encode_local_pane_graphics( - &app, - &crate::app::pane_graphics::Runtime::default(), - &TerminalRuntimeRegistry::new(), - app.view.tab_surface(), - HostCellSize { - width_px: 10, - height_px: 20, - }, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - &mut cache, - ); - - assert!(encoded.bytes.is_empty()); - assert!(cache.oversized.contains_key(&source)); - } - #[test] fn terminal_image_data_requests_deduplicate_and_reconsider_changed_signatures() { let pane_id = PaneId::from_raw(1); @@ -3091,49 +1474,6 @@ mod tests { )); } - #[test] - fn terminal_quarantine_survives_pane_layer_cleanup_and_stays_bounded() { - let terminal_source = HostSourceKey::Terminal { - pane_id: PaneId::from_raw(1), - image_id: 7, - }; - let signature = ImageSignature { - image_width: 3456, - image_height: 2234, - format_code: 32, - data_len: 3456 * 2234 * 4, - data_fingerprint: 42, - }; - let pane_source = HostSourceKey::PaneLayer { - pane_id: PaneId::from_raw(1), - layer_id: "primary".into(), - }; - let mut cache = HostGraphicsCache::default(); - cache.quarantine_oversized(terminal_source.clone(), signature); - cache.sources.insert(pane_source, 99); - cache.images.insert(99, signature); - - let cleared = String::from_utf8(cache.clear_pane_sources()).unwrap(); - assert!(cleared.contains("a=d,d=I,i=99")); - assert!(cache.oversized.contains_key(&terminal_source)); - - for image_id in 0..=MAX_OVERSIZED_SOURCES as u32 { - cache.quarantine_oversized( - HostSourceKey::Terminal { - pane_id: PaneId::from_raw(2), - image_id, - }, - ImageSignature { - data_fingerprint: u64::from(image_id), - ..signature - }, - ); - } - assert_eq!(cache.oversized.len(), MAX_OVERSIZED_SOURCES); - cache.clear_bytes(); - assert!(cache.oversized.is_empty()); - } - #[test] fn maximum_pane_graphics_stream_payload_fits_client_graphics_frame() { let mut placement = pane_layer_placement(0, 0); diff --git a/src/logging.rs b/src/logging.rs index ff85799b..2d1cd5a1 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -254,19 +254,6 @@ pub(crate) fn workspace_renamed(workspace_id: &str) { ); } -#[cfg(test)] -pub(crate) fn tab_created(workspace_id: &str, tab_id: &str, root_pane_id: u32) { - tracing::info!( - event = "tab.create", - subsystem = "tab", - outcome = "ok", - workspace_id, - tab_id, - pane_id = root_pane_id, - "tab created" - ); -} - pub(crate) fn tab_focused(workspace_id: &str, tab_id: &str) { tracing::info!( event = "tab.focus", @@ -383,18 +370,6 @@ pub(crate) fn update_available(version: &str) { ); } -pub(crate) fn config_write_failed(path: &Path, context: &str, err: &str) { - tracing::warn!( - event = "config.write", - subsystem = "config", - outcome = "error", - path = %path.display(), - context, - err, - "failed to write config" - ); -} - pub(crate) fn integration_action( action: &'static str, target: &'static str, diff --git a/src/main.rs b/src/main.rs index 9a26c647..91a6b2e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ mod checksum; mod cli; mod client; mod config; +mod copy_mode; mod detect; mod events; mod ghostty; diff --git a/src/pane.rs b/src/pane.rs index dff66f10..626239d5 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -44,8 +44,8 @@ use self::agent_detection::{ pub use self::terminal::InputState; use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; pub(crate) use self::terminal::{ - TerminalDirtyPatch, TerminalDirtyPatchOutcome, TerminalReadSnapshot, TerminalSearchDirection, - TerminalSearchWindow, TerminalTextMatch, TerminalTextPoint, TerminalWordMotion, + TerminalReadSnapshot, TerminalSearchDirection, TerminalSearchWindow, TerminalTextPoint, + TerminalWordMotion, }; pub use self::{ state::PaneState, @@ -56,21 +56,6 @@ const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration:: const PANE_TERM: &str = "xterm-256color"; const PANE_COLORTERM: &str = "truecolor"; -#[cfg(test)] -thread_local! { - static AGGREGATE_INPUT_STATE_READS: Cell = const { Cell::new(0) }; -} - -#[cfg(test)] -pub(crate) fn reset_aggregate_input_state_reads() { - AGGREGATE_INPUT_STATE_READS.set(0); -} - -#[cfg(test)] -pub(crate) fn aggregate_input_state_reads() -> usize { - AGGREGATE_INPUT_STATE_READS.get() -} - fn apply_pane_terminal_env(cmd: &mut CommandBuilder) { // Each pane is rendered by herdr's own terminal layer, not the outer terminal // that launched the app. Advertising the inherited TERM leaks the host terminal @@ -2607,11 +2592,6 @@ impl PaneRuntime { self.detect_reset_notify.clone() } - #[cfg(test)] - pub(crate) fn agent_detection_enabled_for_test(&self) -> bool { - self.detect_handle.is_some() - } - pub fn set_full_lifecycle_authority_active(&self, active: bool) { let previous = self .full_lifecycle_authority_active @@ -2690,14 +2670,6 @@ impl PaneRuntime { self.terminal.scroll_metrics() } - pub(crate) fn search_text_matches( - &self, - query: &str, - case_sensitive: bool, - ) -> Vec { - self.terminal.search_text_matches(query, case_sensitive) - } - pub(crate) fn search_text_window( &self, query: &str, @@ -2714,17 +2686,6 @@ impl PaneRuntime { .search_text_window(query, case_sensitive, direction, cursor, previous, limit) } - pub(crate) fn text_match_is_current(&self, text_match: crate::pane::TerminalTextMatch) -> bool { - self.terminal.text_match_is_current(text_match) - } - - pub(crate) fn text_matches_are_current( - &self, - text_matches: &[crate::pane::TerminalTextMatch], - ) -> Vec { - self.terminal.text_matches_are_current(text_matches) - } - pub(crate) fn word_motion_target( &self, row: u32, @@ -2748,15 +2709,9 @@ impl PaneRuntime { #[cfg(any(unix, test))] pub fn input_state(&self) -> Option { - #[cfg(test)] - AGGREGATE_INPUT_STATE_READS.set(AGGREGATE_INPUT_STATE_READS.get() + 1); self.terminal.input_state() } - pub fn keyboard_report_all_requested(&self) -> bool { - self.terminal.keyboard_report_all_requested() - } - pub fn bracketed_paste_enabled(&self) -> bool { self.terminal.bracketed_paste_enabled() } @@ -2858,14 +2813,6 @@ impl PaneRuntime { self.terminal.render(frame, area, show_cursor); } - pub(crate) fn collect_dirty_patch( - &self, - area_width: u16, - area_height: u16, - ) -> TerminalDirtyPatchOutcome { - self.terminal.collect_dirty_patch(area_width, area_height) - } - pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> { self.terminal.visible_hyperlinks(area) } @@ -2888,6 +2835,10 @@ impl PaneRuntime { self.terminal.keyboard_protocol(fallback) } + pub fn modify_other_keys_level(&self) -> u8 { + self.terminal.modify_other_keys_level() + } + pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { self.terminal .encode_terminal_key(key, self.keyboard_protocol()) diff --git a/src/pane/kitty_keyboard.rs b/src/pane/kitty_keyboard.rs index 30ab62ba..74de2538 100644 --- a/src/pane/kitty_keyboard.rs +++ b/src/pane/kitty_keyboard.rs @@ -3,8 +3,7 @@ pub(crate) struct KittyKeyboardTracker { pending: Vec, stack: Vec, flags: u16, - #[cfg(windows)] - modify_other_keys: bool, + modify_other_keys_level: u8, } impl KittyKeyboardTracker { @@ -32,9 +31,10 @@ impl KittyKeyboardTracker { self.store_pending(&bytes[index..]); break; } - #[cfg(windows)] if bytes[index + 1] == b'c' { - self.modify_other_keys = false; + self.stack.clear(); + self.flags = 0; + self.modify_other_keys_level = 0; } if bytes[index + 1] != b'[' { index += 1; @@ -52,7 +52,6 @@ impl KittyKeyboardTracker { match bytes[end] { b'u' => self.observe_csi_u(&bytes[index + 2..end]), - #[cfg(windows)] b'm' => self.observe_modify_other_keys(&bytes[index + 2..end]), #[cfg(windows)] b'n' if bytes[index + 2..end] @@ -61,7 +60,7 @@ impl KittyKeyboardTracker { !params.contains(&b';') && parse_kitty_keyboard_flags(params) == 4 }) => { - self.modify_other_keys = false; + self.modify_other_keys_level = 0; } _ => {} } @@ -69,18 +68,21 @@ impl KittyKeyboardTracker { } } - #[cfg(windows)] - pub(crate) fn modify_other_keys_enabled(&self) -> bool { - self.modify_other_keys + pub(crate) fn modify_other_keys_level(&self) -> u8 { + self.modify_other_keys_level } #[cfg(windows)] + pub(crate) fn modify_other_keys_enabled(&self) -> bool { + self.modify_other_keys_level > 0 + } + fn observe_modify_other_keys(&mut self, params: &[u8]) { let Some(params) = params.strip_prefix(b">") else { return; }; if params.is_empty() { - self.modify_other_keys = false; + self.modify_other_keys_level = 0; return; } @@ -91,8 +93,8 @@ impl KittyKeyboardTracker { return; } if parse_kitty_keyboard_flags(resource) == 4 { - self.modify_other_keys = - value.is_some_and(|value| parse_kitty_keyboard_flags(value) != 0); + self.modify_other_keys_level = + value.map(parse_kitty_keyboard_flags).unwrap_or(0).min(2) as u8; } } @@ -128,17 +130,22 @@ impl KittyKeyboardTracker { #[cfg(unix)] pub(crate) fn replay_ansi(&self) -> Option { - if self.stack.is_empty() { - return (self.flags != 0).then(|| format!("\x1b[={}u", self.flags)); - } - let mut ansi = String::new(); - let baseline = self.stack[0]; - if baseline != 0 { - ansi.push_str(&format!("\x1b[={baseline}u")); + if self.stack.is_empty() { + if self.flags != 0 { + ansi.push_str(&format!("\x1b[={}u", self.flags)); + } + } else { + let baseline = self.stack[0]; + if baseline != 0 { + ansi.push_str(&format!("\x1b[={baseline}u")); + } + for flags in self.stack.iter().skip(1).copied().chain([self.flags]) { + ansi.push_str(&format!("\x1b[>{flags}u")); + } } - for flags in self.stack.iter().skip(1).copied().chain([self.flags]) { - ansi.push_str(&format!("\x1b[>{flags}u")); + if self.modify_other_keys_level > 0 { + ansi.push_str(&format!("\x1b[>4;{}m", self.modify_other_keys_level)); } (!ansi.is_empty()).then_some(ansi) } @@ -166,6 +173,7 @@ mod tests { assert_eq!(tracker.flags, 1); assert_eq!(tracker.stack, vec![0]); + assert_eq!(tracker.modify_other_keys_level(), 1); #[cfg(windows)] { assert!(tracker.modify_other_keys_enabled()); @@ -175,4 +183,31 @@ mod tests { assert!(!tracker.modify_other_keys_enabled()); } } + + #[test] + fn ris_clears_kitty_flags_stack_and_modify_other_keys() { + let mut tracker = KittyKeyboardTracker::default(); + tracker.observe(b"\x1b[>1u\x1b[>5u\x1b[>4;2m\x1bc"); + + assert_eq!(tracker.flags, 0); + assert!(tracker.stack.is_empty()); + assert_eq!(tracker.modify_other_keys_level(), 0); + #[cfg(unix)] + assert_eq!(tracker.replay_ansi(), None); + } + + #[test] + fn tracks_and_replays_exact_modify_other_keys_level() { + let mut tracker = KittyKeyboardTracker::default(); + + tracker.observe(b"\x1b[>4;1m"); + assert_eq!(tracker.modify_other_keys_level(), 1); + #[cfg(unix)] + assert_eq!(tracker.replay_ansi().as_deref(), Some("\x1b[>4;1m")); + + tracker.observe(b"\x1b[>4;2m"); + assert_eq!(tracker.modify_other_keys_level(), 2); + tracker.observe(b"\x1b[>4;0m"); + assert_eq!(tracker.modify_other_keys_level(), 0); + } } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 96c2b925..c3f8e41d 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -14,7 +14,6 @@ use tracing::{debug, error}; use unicode_width::UnicodeWidthStr; use crate::layout::PaneId; -use crate::protocol::CellData; #[cfg(windows)] mod windows_recent_fallback; @@ -103,18 +102,6 @@ pub struct TerminalCursorState { pub shape: u8, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct TerminalDirtyPatch { - pub rows: Vec<(u16, Vec)>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum TerminalDirtyPatchOutcome { - Clean, - Patch(TerminalDirtyPatch), - Fallback, -} - fn decscusr_cursor_shape(style: crate::ghostty::CursorVisualStyle, blinking: bool) -> u8 { match (style, blinking) { (crate::ghostty::CursorVisualStyle::Block, true) @@ -255,17 +242,6 @@ impl PaneTerminal { self.ghostty.scroll_metrics() } - pub(crate) fn search_text_matches( - &self, - query: &str, - case_sensitive: bool, - ) -> Vec { - let Some((buffer, active_screen)) = self.retained_text_buffer() else { - return Vec::new(); - }; - buffer.search(query, case_sensitive, active_screen) - } - pub(crate) fn search_text_window( &self, query: &str, @@ -294,60 +270,6 @@ impl PaneTerminal { ) } - pub(crate) fn text_match_is_current(&self, text_match: TerminalTextMatch) -> bool { - self.text_matches_are_current(&[text_match]) - .first() - .copied() - .unwrap_or(false) - } - - pub(crate) fn text_matches_are_current(&self, text_matches: &[TerminalTextMatch]) -> Vec { - if text_matches.is_empty() { - return Vec::new(); - } - let Ok(core) = self.ghostty.core.lock() else { - return vec![false; text_matches.len()]; - }; - let Some(cols) = core.terminal.cols().ok() else { - return vec![false; text_matches.len()]; - }; - let Some(active_screen) = core.terminal.active_screen().ok() else { - return vec![false; text_matches.len()]; - }; - let row_range = text_matches - .iter() - .filter(|text_match| { - text_match.scan_cols == cols && text_match.scan_screen == active_screen - }) - .fold(None::<(u32, u32)>, |range, text_match| { - Some(match range { - Some((start_row, end_row)) => ( - start_row.min(text_match.start.row), - end_row.max(text_match.end.row), - ), - None => (text_match.start.row, text_match.end.row), - }) - }); - let Some((start_row, end_row)) = row_range else { - return vec![false; text_matches.len()]; - }; - let Ok(rows) = core - .terminal - .screen_text_rows_range(start_row as usize, end_row.saturating_add(1) as usize) - else { - return vec![false; text_matches.len()]; - }; - let buffer = RetainedTextBuffer::new_search(cols, rows, start_row); - text_matches - .iter() - .map(|text_match| { - text_match.scan_cols == cols - && text_match.scan_screen == active_screen - && buffer.contains_match(*text_match) - }) - .collect() - } - pub(crate) fn word_motion_target( &self, row: u32, @@ -482,10 +404,6 @@ impl PaneTerminal { self.ghostty.input_state() } - pub fn keyboard_report_all_requested(&self) -> bool { - self.ghostty.keyboard_report_all_requested() - } - pub fn bracketed_paste_enabled(&self) -> bool { self.ghostty.bracketed_paste_enabled() } @@ -498,6 +416,10 @@ impl PaneTerminal { self.ghostty.mouse_reporting_enabled() } + pub fn modify_other_keys_level(&self) -> u8 { + self.ghostty.modify_other_keys_level() + } + pub fn sgr_pixel_mouse_enabled(&self) -> bool { self.ghostty.sgr_pixel_mouse_enabled() } @@ -572,14 +494,6 @@ impl PaneTerminal { self.ghostty.render(frame, area, show_cursor); } - pub fn collect_dirty_patch( - &self, - area_width: u16, - area_height: u16, - ) -> TerminalDirtyPatchOutcome { - self.ghostty.collect_dirty_patch(area_width, area_height) - } - pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> { self.ghostty.visible_hyperlinks(area) } @@ -619,12 +533,10 @@ impl PaneTerminal { self.ghostty.terminal_title() } - #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_title(&self) -> String { self.ghostty.agent_osc_title() } - #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_progress(&self) -> String { self.ghostty.agent_osc_progress() } @@ -831,50 +743,6 @@ impl RetainedTextBuffer { Self { cols, lines, atoms } } - fn search( - &self, - query: &str, - case_sensitive: bool, - active_screen: crate::ghostty::ActiveScreen, - ) -> Vec { - if query.is_empty() { - return Vec::new(); - } - let Ok(regex) = regex::RegexBuilder::new(®ex::escape(query)) - .case_insensitive(!case_sensitive) - .build() - else { - return Vec::new(); - }; - let mut matches = Vec::new(); - for line in &self.lines { - for found in regex.find_iter(&line.text) { - let Ok(start_index) = line - .spans - .binary_search_by_key(&found.start(), |span| span.byte_start) - else { - continue; - }; - let Ok(end_index) = line - .spans - .binary_search_by_key(&found.end(), |span| span.byte_end) - else { - continue; - }; - let start_span = &line.spans[start_index]; - let end_span = &line.spans[end_index]; - matches.push(TerminalTextMatch { - start: start_span.start, - end: end_span.end, - source_fingerprint: text_fingerprint(found.as_str()), - scan_cols: self.cols, - scan_screen: active_screen, - }); - } - } - matches - } - fn search_window( &self, query: &str, @@ -993,28 +861,6 @@ impl RetainedTextBuffer { } } - fn contains_match(&self, text_match: TerminalTextMatch) -> bool { - self.lines.iter().any(|line| { - let Ok(start_index) = line - .spans - .binary_search_by_key(&text_match.start, |span| span.start) - else { - return false; - }; - let Ok(end_index) = line - .spans - .binary_search_by_key(&text_match.end, |span| span.end) - else { - return false; - }; - let start_span = &line.spans[start_index]; - let end_span = &line.spans[end_index]; - start_span.byte_start <= end_span.byte_end - && text_fingerprint(&line.text[start_span.byte_start..end_span.byte_end]) - == text_match.source_fingerprint - }) - } - fn word_motion( &self, row: u32, @@ -1394,7 +1240,6 @@ impl GhosttyPaneTerminal { /// Returns the latest OSC 0/2 title retained for agent detection, or `""` /// if no title has been seen or the last update was an empty clear. - #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_title(&self) -> String { self.core .lock() @@ -1404,7 +1249,6 @@ impl GhosttyPaneTerminal { /// Returns the latest OSC 9 progress payload retained for agent detection, /// or `""` if none has been seen. - #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_progress(&self) -> String { self.core .lock() @@ -1656,7 +1500,6 @@ impl GhosttyPaneTerminal { let Ok(mut core) = self.core.lock() else { return; }; - #[cfg(windows)] core.kitty_keyboard.observe(ansi.as_bytes()); core.terminal.write(ansi.as_bytes()); #[cfg(windows)] @@ -1721,6 +1564,7 @@ impl GhosttyPaneTerminal { } if input_state.modify_other_keys { + core.kitty_keyboard.observe(b"\x1b[>4;2m"); core.terminal.write(b"\x1b[>4;2m"); } @@ -1882,17 +1726,6 @@ impl GhosttyPaneTerminal { core.kitty_keyboard.replay_ansi() } - pub fn keyboard_report_all_requested(&self) -> bool { - self.core.lock().is_ok_and(|core| { - let protocol = crate::input::KeyboardProtocol::from_kitty_flags( - core.terminal.kitty_keyboard_flags().unwrap_or(0) as u16, - ); - protocol.reports_all_keys() - || (protocol.reports_event_types() - && core.terminal.modify_other_keys_enabled().unwrap_or(false)) - }) - } - pub fn bracketed_paste_enabled(&self) -> bool { self.mode_enabled(crate::ghostty::MODE_BRACKETED_PASTE) } @@ -1907,6 +1740,12 @@ impl GhosttyPaneTerminal { .is_ok_and(|core| core.terminal.mouse_tracking_enabled().unwrap_or(false)) } + pub fn modify_other_keys_level(&self) -> u8 { + self.core + .lock() + .map_or(0, |core| core.kitty_keyboard.modify_other_keys_level()) + } + pub fn sgr_pixel_mouse_enabled(&self) -> bool { self.mode_enabled(crate::ghostty::MODE_MOUSE_SGR_PIXELS) } @@ -2409,18 +2248,6 @@ impl GhosttyPaneTerminal { } } } - - pub fn collect_dirty_patch( - &self, - area_width: u16, - area_height: u16, - ) -> TerminalDirtyPatchOutcome { - self.core - .lock() - .ok() - .map(|mut core| ghostty_collect_dirty_patch(&mut core, area_width, area_height)) - .unwrap_or(TerminalDirtyPatchOutcome::Fallback) - } } fn encoded_key_preserves_event_kind( @@ -2523,166 +2350,6 @@ fn ghostty_clear_render_dirty(render_state: &mut crate::ghostty::RenderState, ar let _ = render_state.set_dirty(crate::ghostty::Dirty::Clean); } -fn ghostty_collect_dirty_patch( - core: &mut GhosttyPaneCore, - area_width: u16, - area_height: u16, -) -> TerminalDirtyPatchOutcome { - let prof_started = crate::render_prof::timer(); - macro_rules! finish { - ($outcome:expr) => {{ - let outcome = $outcome; - if let Some(started) = prof_started { - crate::render_prof::duration("dirty_collect.total", started.elapsed()); - match &outcome { - TerminalDirtyPatchOutcome::Clean => { - crate::render_prof::event("dirty_collect.clean"); - } - TerminalDirtyPatchOutcome::Fallback => { - crate::render_prof::event("dirty_collect.fallback"); - } - TerminalDirtyPatchOutcome::Patch(patch) => { - crate::render_prof::event("dirty_collect.patch"); - crate::render_prof::counter("dirty_collect.rows", patch.rows.len() as u64); - let cells = patch.rows.iter().map(|(_, cells)| cells.len() as u64).sum(); - crate::render_prof::counter("dirty_collect.cells", cells); - } - } - } - return outcome; - }}; - } - macro_rules! fallback { - ($reason:literal) => {{ - crate::render_prof::event(concat!("dirty_fallback.", $reason)); - finish!(TerminalDirtyPatchOutcome::Fallback); - }}; - } - - let host_theme = core.host_terminal_theme; - let initial_default_foreground = core.initial_default_foreground; - let initial_default_background = core.initial_default_background; - let GhosttyPaneCore { - terminal, - render_state, - .. - } = core; - if render_state.update(terminal).is_err() { - fallback!("render_state_update_error"); - } - match render_state.dirty() { - Ok(crate::ghostty::Dirty::Clean) => finish!(TerminalDirtyPatchOutcome::Clean), - Ok(crate::ghostty::Dirty::Partial) => {} - Ok(crate::ghostty::Dirty::Full) => fallback!("dirty_full"), - Err(_) => fallback!("dirty_read_error"), - } - - let colors = render_state.colors().ok(); - let default_bg = colors - .and_then(|c| ghostty_default_bg(c.background, host_theme, initial_default_background)); - let default_fg = colors - .and_then(|c| ghostty_default_fg(c.foreground, host_theme, initial_default_foreground)); - let resolved_fg = colors.map(|c| ghostty_color(c.foreground)); - let resolved_bg = colors.map(|c| ghostty_color(c.background)); - let palette_overrides = colors - .zip(terminal.default_palette().ok()) - .and_then(|(colors, default)| PaletteOverrides::new(&colors.palette, &default)); - let hide_kitty_placeholders = crate::kitty_graphics::is_enabled(); - - let Ok(mut row_iterator) = crate::ghostty::RowIterator::new() else { - fallback!("row_iterator_new_error"); - }; - let Ok(mut row_cells) = crate::ghostty::RowCells::new() else { - fallback!("row_cells_new_error"); - }; - let Ok(mut rows) = render_state.populate_row_iterator(&mut row_iterator) else { - fallback!("populate_rows_error"); - }; - let mut grapheme_bytes = Vec::new(); - let mut symbol_scratch = String::new(); - let mut patch_rows = Vec::new(); - let mut y = 0u16; - while y < area_height && rows.next() { - let Ok(dirty) = rows.dirty() else { - fallback!("row_dirty_read_error"); - }; - if dirty { - match rows.selection() { - Ok(None) => {} - Ok(Some(_)) => fallback!("row_selection_present"), - Err(_) => fallback!("row_selection_error"), - } - let Ok(mut cells) = rows.populate_cells(&mut row_cells) else { - fallback!("populate_cells_error"); - }; - let mut patch_cells = Vec::with_capacity(usize::from(area_width)); - let mut x = 0u16; - while x < area_width && cells.next() { - let Ok(basic) = cells.basic_data() else { - fallback!("basic_data_error"); - }; - if basic.has_hyperlink { - fallback!("hyperlink_present"); - } - let style = ghostty_cell_style( - &cells, - &basic, - default_fg, - default_bg, - resolved_fg, - resolved_bg, - palette_overrides.as_ref(), - ); - let symbol = match ghostty_buffer_symbol_into( - &cells, - basic.wide, - hide_kitty_placeholders, - &mut grapheme_bytes, - &mut symbol_scratch, - ) { - Ok(symbol) => symbol.to_owned(), - Err(_) => ghostty_blank_symbol_for_width(basic.wide).to_owned(), - }; - patch_cells.push(cell_data_from_style(symbol, style)); - x += 1; - } - while x < area_width { - patch_cells.push(blank_cell_data(default_fg, default_bg)); - x += 1; - } - patch_rows.push((y, patch_cells)); - } - y += 1; - } - - let dirty_ys: std::collections::HashSet = patch_rows.iter().map(|(row, _)| *row).collect(); - if !dirty_ys.is_empty() { - let Ok(mut clear_row_iterator) = crate::ghostty::RowIterator::new() else { - fallback!("clear_row_iterator_new_error"); - }; - let Ok(mut clear_rows) = render_state.populate_row_iterator(&mut clear_row_iterator) else { - fallback!("clear_populate_rows_error"); - }; - let mut clear_y = 0u16; - while clear_y < area_height && clear_rows.next() { - if dirty_ys.contains(&clear_y) && clear_rows.clear_dirty().is_err() { - fallback!("clear_dirty_error"); - } - clear_y += 1; - } - } - if render_state - .set_dirty(crate::ghostty::Dirty::Clean) - .is_err() - { - fallback!("set_clean_error"); - } - - finish!(TerminalDirtyPatchOutcome::Patch(TerminalDirtyPatch { - rows: patch_rows - })); -} - fn ghostty_visible_hyperlinks( core: &mut GhosttyPaneCore, area: Rect, @@ -3125,24 +2792,6 @@ fn ghostty_reset_cell( } } -fn blank_cell_data(default_fg: Option, default_bg: Option) -> CellData { - cell_data_from_style( - " ".to_string(), - ghostty_default_style(default_fg, default_bg), - ) -} - -fn cell_data_from_style(symbol: String, style: Style) -> CellData { - CellData { - symbol, - fg: crate::protocol::color_to_u32(style.fg.unwrap_or(Color::Reset)), - bg: crate::protocol::color_to_u32(style.bg.unwrap_or(Color::Reset)), - modifier: crate::protocol::modifier_to_u16(style.add_modifier), - skip: false, - hyperlink: None, - } -} - fn ghostty_default_style(default_fg: Option, default_bg: Option) -> Style { let mut style = Style::default(); if let Some(fg) = default_fg { @@ -3626,7 +3275,17 @@ mod tests { query: &str, case_sensitive: bool, ) -> Vec { - buffer.search(query, case_sensitive, crate::ghostty::ActiveScreen::Primary) + buffer + .search_window( + query, + case_sensitive, + crate::ghostty::ActiveScreen::Primary, + TerminalSearchDirection::Forward, + TerminalTextPoint { row: 0, col: 0 }, + None, + usize::MAX, + ) + .matches } fn write_numbered_lines(terminal: &mut crate::ghostty::Terminal, count: usize) { @@ -3858,56 +3517,6 @@ mod tests { ); } - #[test] - fn live_terminal_match_validation_rejects_overwritten_text() { - let (tx, _rx) = mpsc::channel(4); - let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap(); - terminal.write(b"alpha needle"); - let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - - let text_match = pane.search_text_matches("needle", true)[0]; - assert!(pane.text_match_is_current(text_match)); - pane.ghostty - .core - .lock() - .unwrap() - .terminal - .write(b"\r\x1b[2Kalpha changed"); - assert!(!pane.text_match_is_current(text_match)); - } - - #[test] - fn live_terminal_match_validation_handles_soft_wrapped_matches() { - let (tx, _rx) = mpsc::channel(4); - let mut terminal = crate::ghostty::Terminal::new(5, 3, 100).unwrap(); - terminal.write(b"abcdef"); - let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - - let text_match = pane.search_text_matches("def", true)[0]; - - assert_eq!(text_match.start, TerminalTextPoint { row: 0, col: 3 }); - assert_eq!(text_match.end, TerminalTextPoint { row: 1, col: 0 }); - assert!(pane.text_match_is_current(text_match)); - } - - #[test] - fn live_terminal_match_validation_rejects_an_active_screen_change() { - let (tx, _rx) = mpsc::channel(4); - let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap(); - terminal.write(b"alpha needle"); - let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - - let text_match = pane.search_text_matches("needle", true)[0]; - pane.ghostty - .core - .lock() - .unwrap() - .terminal - .write(b"\x1b[?1049hneedle"); - - assert!(!pane.text_match_is_current(text_match)); - } - #[test] fn live_terminal_word_motion_expands_across_long_blank_history() { let (tx, _rx) = mpsc::channel(4); @@ -3932,7 +3541,16 @@ mod tests { let word = "a".repeat(132); terminal.write(word.as_bytes()); let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - let text_match = pane.search_text_matches(&word, true)[0]; + let text_match = pane + .search_text_window( + &word, + true, + TerminalSearchDirection::Forward, + TerminalTextPoint { row: 0, col: 0 }, + None, + 1, + ) + .matches[0]; assert_eq!( pane.word_motion_target( @@ -3951,7 +3569,16 @@ mod tests { let word = "界".repeat(66); terminal.write(word.as_bytes()); let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - let text_match = pane.search_text_matches(&word, true)[0]; + let text_match = pane + .search_text_window( + &word, + true, + TerminalSearchDirection::Forward, + TerminalTextPoint { row: 0, col: 0 }, + None, + 1, + ) + .matches[0]; // The word end sits on the head cell of the final wide glyph, past the // initial read window, so the window has to expand to reach it. @@ -4683,6 +4310,7 @@ mod tests { color_scheme_reporting: true, }) ); + assert_eq!(pane.modify_other_keys_level(), 2); let encoded = pane.encode_terminal_key( crate::input::TerminalKey::new( @@ -4900,6 +4528,7 @@ mod tests { let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); pane.seed_history_ansi("\x1b[>4;1m"); + assert_eq!(pane.modify_other_keys_level(), 1); let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); assert_eq!(encoded, b"\x1b[27;2;13~"); @@ -5259,35 +4888,6 @@ mod tests { assert_eq!(buffer[(2, 0)].symbol(), "Z"); } - #[test] - fn dirty_patch_keeps_halfwidth_katakana_voiced_tail_empty() { - let (tx, _rx) = mpsc::channel(4); - let terminal = crate::ghostty::Terminal::new(20, 1, 0).unwrap(); - let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let backend = ratatui::backend::TestBackend::new(20, 1); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 1), false)) - .unwrap(); - { - let mut core = pane.core.lock().unwrap(); - core.terminal.write("ガZ".as_bytes()); - } - - let patch = match pane.collect_dirty_patch(20, 1) { - TerminalDirtyPatchOutcome::Patch(patch) => patch, - other => panic!("expected dirty patch, got {other:?}"), - }; - let row = &patch.rows[0].1; - - assert_eq!(row[0].symbol, "カ\u{ff9e}"); - assert_eq!( - row[1].symbol, "", - "wide spacer tail must stay empty in retained terminal patches" - ); - assert_eq!(row[2].symbol, "Z"); - } - #[test] fn pane_scrollback_controls_round_trip_and_clamp_without_ui_interference() { let (tx, _rx) = mpsc::channel(4); @@ -6180,34 +5780,6 @@ mod tests { assert_eq!(style.underline_color, Some(Color::Rgb(17, 34, 51))); } - #[test] - fn dirty_patch_preserves_curly_underline_style() { - let (tx, _rx) = mpsc::channel(4); - let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); - let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let backend = ratatui::backend::TestBackend::new(20, 5); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false)) - .unwrap(); - { - let mut core = pane.core.lock().unwrap(); - core.terminal.write(b"\x1b[4:3mU"); - } - - let patch = match pane.collect_dirty_patch(20, 5) { - TerminalDirtyPatchOutcome::Patch(patch) => patch, - other => panic!("expected dirty patch, got {other:?}"), - }; - - let cell = &patch.rows[0].1[0]; - assert_eq!(cell.symbol, "U"); - assert_eq!( - crate::protocol::underline_style_from_modifier(cell.modifier), - 3 - ); - } - #[test] fn full_frame_preserves_curly_underline_style() { let (tx, _rx) = mpsc::channel(4); diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index 39f790dd..e6ca70f9 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -258,9 +258,6 @@ pub fn capture( terminal_runtimes: &TerminalRuntimeRegistry, active: Option, selected: usize, - sidebar_width: u16, - sidebar_section_split: f32, - collapsed_space_keys: std::collections::HashSet, ) -> SessionSnapshot { SessionSnapshot { version: SNAPSHOT_VERSION, @@ -270,9 +267,9 @@ pub fn capture( .collect(), active, selected, - sidebar_width: Some(sidebar_width), - sidebar_section_split: Some(sidebar_section_split), - collapsed_space_keys, + sidebar_width: None, + sidebar_section_split: None, + collapsed_space_keys: std::collections::HashSet::new(), } } @@ -538,9 +535,6 @@ mod tests { terminal_runtimes, state.active, state.selected, - state.sidebar_width, - state.sidebar_section_split, - state.collapsed_space_keys.clone(), ) } @@ -867,16 +861,13 @@ mod tests { } #[test] - fn capture_contract_tracks_sidebar_state() { - let mut state = state_with_workspaces(&["one"]); - state.sidebar_width = 31; - state.sidebar_section_split = 0.4; - state.collapsed_space_keys.insert("repo-key".into()); + fn capture_contract_omits_legacy_server_chrome_state() { + let state = state_with_workspaces(&["one"]); let snapshot = capture_from_state(&state); - assert_eq!(snapshot.sidebar_width, Some(31)); - assert_eq!(snapshot.sidebar_section_split, Some(0.4)); - assert!(snapshot.collapsed_space_keys.contains("repo-key")); + assert_eq!(snapshot.sidebar_width, None); + assert_eq!(snapshot.sidebar_section_split, None); + assert!(snapshot.collapsed_space_keys.is_empty()); } #[test] @@ -920,7 +911,11 @@ mod tests { let mut state = state_with_workspaces(&["one"]); let root = state.workspaces[0].tabs[0].root_pane; let second = state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut state, Rect::new(0, 0, 106, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + Rect::new(0, 0, 106, 20), + ); state.navigate_pane(NavDirection::Right); @@ -935,7 +930,11 @@ mod tests { let root = state.workspaces[0].tabs[0].root_pane; state.workspaces[0].test_split(Direction::Horizontal); state.workspaces[0].layout.focus_pane(root); - crate::ui::compute_view(&mut state, Rect::new(0, 0, 106, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + Rect::new(0, 0, 106, 20), + ); let before = capture_from_state(&state); state.resize_pane(NavDirection::Right); diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 64e5467b..7e14dcd8 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -220,8 +220,6 @@ pub fn open_url(_url: &str) -> std::io::Result> { } /// Unsupported platform stub. -// Windows does not wire clipboard-image bridging into semantic input yet. -#[cfg_attr(windows, allow(dead_code))] pub fn read_clipboard_image() -> Option { None } diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs index 97685d4d..6bc690b7 100644 --- a/src/protocol/render_ansi.rs +++ b/src/protocol/render_ansi.rs @@ -134,10 +134,6 @@ impl BlitEncoder { pub(crate) fn is_current(&self, frame: &FrameData) -> bool { self.last_frame.as_ref() == Some(frame) } - - pub(crate) fn last_frame(&self) -> Option<&FrameData> { - self.last_frame.as_ref() - } } pub(crate) fn frame_with_drawn_cursor(mut frame: FrameData) -> FrameData { diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 1f09d10b..bdc7ed67 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -43,26 +43,6 @@ pub enum RenderEncoding { TerminalAnsi, } -/// Keybinding profile requested by an attached app client. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ClientKeybindings { - /// Use the server's own keybinding config. - Server, - /// Use this attached client's normalized local `[keys]` config. - Local { keys_toml: String }, -} - -/// Client behavior requested at connection time. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ClientLaunchMode { - /// Full app client rendered by the server. - App, - /// Full app client eligible for audited local direct graphics. - AppDirectGraphics, - /// Direct terminal attach client. - TerminalAttach, -} - /// Size of the pane surface requested by a client-owned shell. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct ClientSurfaceSize { @@ -77,7 +57,7 @@ pub enum ClientKeyKind { Release, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ClientKeyCode { Backspace, Enter, @@ -99,7 +79,7 @@ pub enum ClientKeyCode { Null, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ClientMouseButton { Left, Right, @@ -132,6 +112,15 @@ pub enum ClientMousePosition { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientMouseGeometry { + pub cols: u16, + pub rows: u16, + pub width_px: u32, + pub height_px: u32, +} + +#[cfg(any(windows, test))] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientInputEvent { Key { @@ -169,17 +158,21 @@ pub enum ClientPaneInputEvent { repeat_count: u16, shifted_codepoint: Option, generated_text: Option, + tracks_release: bool, + physical_key_id: Option, }, TextCommit(String), Mouse { kind: ClientMouseKind, position: ClientMousePosition, + geometry: Option, modifiers: u8, lines: u16, }, Paste(String), } +#[cfg(any(windows, test))] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientKeySource { Synthesized, @@ -310,6 +303,8 @@ impl ClientMouseKind { impl ClientPaneInputEvent { pub(crate) fn from_terminal_key(key: crate::input::TerminalKey) -> Option { + let tracks_release = key.generated_text.is_none() || key.has_physical_identity(); + let physical_key_id = key.physical_key_id(); Some(Self::Key { code: ClientKeyCode::from_crossterm(key.code)?, modifiers: key.modifiers.bits(), @@ -317,6 +312,8 @@ impl ClientPaneInputEvent { repeat_count: key.repeat_count, shifted_codepoint: key.shifted_codepoint, generated_text: key.generated_text, + tracks_release, + physical_key_id, }) } @@ -329,6 +326,8 @@ impl ClientPaneInputEvent { repeat_count, shifted_codepoint, generated_text, + tracks_release, + .. } => { let mut key = crate::input::TerminalKey::new( code.to_crossterm(), @@ -336,7 +335,8 @@ impl ClientPaneInputEvent { ) .with_kind(kind.to_crossterm()) .with_repeat_count(*repeat_count) - .with_generated_text(generated_text.clone()); + .with_generated_text(generated_text.clone()) + .with_physical_identity_hint(*tracks_release && generated_text.is_some()); if let Some(shifted_codepoint) = shifted_codepoint { key = key.with_shifted_codepoint(*shifted_codepoint); } @@ -367,8 +367,8 @@ impl ClientPaneInputEvent { } } +#[cfg(any(windows, test))] impl ClientInputEvent { - #[cfg(any(windows, test))] pub(crate) fn from_crossterm(event: crossterm::event::Event) -> Option { match event { crossterm::event::Event::Key(key) => Some(Self::Key { @@ -441,24 +441,14 @@ impl ClientInputEvent { /// Messages sent from the client to the server over the client protocol socket. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientMessage { - /// Handshake: client announces its protocol version and terminal dimensions. - Hello { - /// Protocol version the client speaks. + /// Direct terminal handshake: announces protocol version and terminal dimensions. + TerminalHello { version: u32, - /// Terminal width in columns. cols: u16, - /// Terminal height in rows. rows: u16, - /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_width_px: u32, - /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_height_px: u32, - /// Render encoding requested by the client. - requested_encoding: RenderEncoding, - /// Keybinding profile requested by the client. - keybindings: ClientKeybindings, - /// Whether this connection will render the full app or attach directly to one terminal. - launch_mode: ClientLaunchMode, + pixel_mouse: bool, }, /// Raw input bytes read from the client's stdin. @@ -469,6 +459,8 @@ pub enum ClientMessage { /// Image bytes read from the client's local clipboard for remote paste bridging. ClipboardImage { + /// Stable terminal target selected by the client that read the clipboard. + target: ClientClipboardImageTarget, /// Image file extension without a leading dot. extension: String, /// Raw image bytes. @@ -483,8 +475,10 @@ pub enum ClientMessage { rows: u16, /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_width_px: u32, - /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. + /// Height of a terminal cell in physical pixels, or 0 when unavailable. cell_height_px: u32, + /// Whether this resize carries coherent exact geometry for SGR pixel mouse input. + pixel_mouse: bool, }, /// Graceful disconnect request. @@ -514,9 +508,6 @@ pub enum ClientMessage { modifiers: u8, }, - /// Structured input events from platform clients that do not expose Unix-style raw bytes. - InputEvents { events: Vec }, - /// Switch this connection into read-only terminal observe mode. ObserveTerminal { /// Pane, terminal, or agent target to observe. @@ -538,40 +529,30 @@ pub enum ClientMessage { success: bool, }, - /// One confirmed SGR 1016 mouse report with read-time host geometry. - InputPixels { - data: Vec, - cols: u16, - rows: u16, - width_px: u32, - height_px: u32, - }, - /// The direct command was written and flushed; terminal response timing starts now. GraphicsTransmissionStarted { transfer_id: u64, image_id: u32 }, - /// Experimental handshake for a client-owned shell around one pane surface. + /// Handshake for the client-owned shell around one pane surface. ClientShellHello { version: u32, - cols: u16, - rows: u16, cell_width_px: u32, cell_height_px: u32, - requested_encoding: RenderEncoding, surface_size: ClientSurfaceSize, pixel_mouse: bool, direct_graphics: bool, /// Whether the endpoint's keymap, rather than the client's, owns shell bindings. endpoint_keybindings: bool, + /// Whether this client wants shell mouse capture even without pane demand. + mouse_capture: bool, }, - /// Resize the outer terminal and pane viewport of a client-owned shell. + /// Resize the pane viewport of a client-owned shell. ClientShellResize { - cols: u16, - rows: u16, cell_width_px: u32, cell_height_px: u32, surface_size: ClientSurfaceSize, + /// Whether this resize carries coherent exact geometry for SGR pixel mouse input. + pixel_mouse: bool, }, /// Deliver client-classified semantic input directly to a stable pane target. @@ -588,6 +569,80 @@ pub enum ClientMessage { /// Invoke one endpoint operation through this client shell's selected connection. ClientShellEndpointRequest { boot_id: String, request: String }, + + /// Deliver one structured mouse event to a directly attached terminal. + AttachMouse { + kind: ClientMouseKind, + position: ClientMousePosition, + geometry: Option, + modifiers: u8, + lines: u16, + }, + + /// Publish one host terminal color or appearance update observed by a client-owned shell. + ClientShellHostTheme { update: ClientHostThemeUpdate }, + + /// Publish whether the outer terminal containing a client shell has focus. + ClientShellFocus { focused: bool }, + + /// Update this client's shell mouse-capture preference after config reload. + ClientShellMouseCapture { enabled: bool }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientHostColor { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl From for ClientHostColor { + fn from(color: crate::terminal_theme::RgbColor) -> Self { + Self { + r: color.r, + g: color.g, + b: color.b, + } + } +} + +impl From for crate::terminal_theme::RgbColor { + fn from(color: ClientHostColor) -> Self { + Self { + r: color.r, + g: color.g, + b: color.b, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientHostDefaultColorKind { + Foreground, + Background, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientHostAppearance { + Dark, + Light, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientHostThemeUpdate { + DefaultColor { + kind: ClientHostDefaultColorKind, + color: ClientHostColor, + }, + PaletteColors(Vec<(u8, ClientHostColor)>), + Appearance(ClientHostAppearance), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientClipboardImageTarget { + DirectTerminal, + Pane(String), + Popup(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -816,6 +871,8 @@ pub struct ClientShellSnapshot { pub latest_release_notes_available: bool, /// Whether endpoint-owned integration assets need an update. pub integration_updates_available: bool, + /// Endpoint-owned base directory used for new linked worktree checkouts. + pub worktree_directory: String, /// Cached endpoint-owned notes used by the client-rendered overlay. pub release_notes: Option, pub focused_workspace_id: Option, @@ -1191,9 +1248,6 @@ pub enum ServerMessage { error: Option, }, - /// A rendered frame to be displayed by a semantic-frame client. - Frame(FrameData), - /// Terminal bytes to write directly for a terminal-ANSI client. Terminal(TerminalFrame), @@ -1242,20 +1296,6 @@ pub enum ServerMessage { sgr_pixels: bool, }, - /// Whether the focused terminal requests Kitty report-all keyboard input. - KittyKeyboardReportAll { - /// True only while the focused pane requests `REPORT_ALL_KEYS_AS_ESCAPE_CODES`. - enabled: bool, - }, - - /// Apply the prefix-mode ASCII input-source change on the foreground client. - /// `active = true` → switch to an ASCII-capable source (saving the current one); - /// `active = false` → restore the saved source. - PrefixInputSource { - /// Whether the ASCII input source should be active. - active: bool, - }, - /// Ring the foreground client's outer terminal for pane-originated BEL characters. TerminalBell { /// Number of BEL characters parsed from one PTY read. @@ -1270,7 +1310,7 @@ pub enum ServerMessage { transfer_id: u64, leading: Vec, control: String, - /// ClientShell upload identity. `None` retains the released App path. + /// ClientShell upload identity. `None` targets a direct terminal client. surface_asset: Option, }, @@ -1290,6 +1330,16 @@ pub enum ServerMessage { /// Immediate endpoint error that the client-rendered shell must show regardless of notification policy. ClientShellError { message: String }, + /// Exact Kitty keyboard flags requested by a directly attached terminal. + /// Zero restores the host terminal's previous keyboard mode. + DirectTerminalKeyboardProtocol { + flags: u16, + modify_other_keys_level: u8, + }, + + /// Whether the focused pane or popup needs the shell host to report every key. + ClientShellKeyboardReportAll { enabled: bool }, + /// One ordered chunk of the final response to an endpoint operation. ClientShellEndpointResponseChunk { boot_id: String, @@ -1575,15 +1625,45 @@ mod tests { #[test] fn client_hello_roundtrip() { - let msg = ClientMessage::Hello { + let msg = ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: true, + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn client_shell_hello_roundtrip() { + let msg = ClientMessage::ClientShellHello { + version: PROTOCOL_VERSION, + cell_width_px: 8, + cell_height_px: 16, + surface_size: ClientSurfaceSize { cols: 80, rows: 29 }, + pixel_mouse: true, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: true, + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn client_shell_resize_roundtrip() { + let msg = ClientMessage::ClientShellResize { + cell_width_px: 8, + cell_height_px: 16, + surface_size: ClientSurfaceSize { cols: 74, rows: 29 }, + pixel_mouse: true, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = @@ -1603,7 +1683,7 @@ mod tests { } #[test] - fn client_message_wire_tags_preserve_protocol_15_order() { + fn client_message_wire_tags_reflect_current_order() { fn tag(msg: &ClientMessage) -> u8 { *bincode::serde::encode_to_vec(msg, bincode::config::standard()) .unwrap() @@ -1612,21 +1692,33 @@ mod tests { } assert_eq!( - tag(&ClientMessage::Hello { + tag(&ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: false, }), 0 ); + assert_eq!( + tag(&ClientMessage::ClientShellHello { + version: PROTOCOL_VERSION, + cell_width_px: 8, + cell_height_px: 16, + surface_size: ClientSurfaceSize { cols: 80, rows: 29 }, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + }), + 11 + ); assert_eq!(tag(&ClientMessage::Input { data: Vec::new() }), 1); assert_eq!( tag(&ClientMessage::ClipboardImage { + target: ClientClipboardImageTarget::DirectTerminal, extension: "png".to_owned(), data: Vec::new(), }), @@ -1638,6 +1730,7 @@ mod tests { rows: 24, cell_width_px: 8, cell_height_px: 16, + pixel_mouse: false, }), 3 ); @@ -1660,84 +1753,40 @@ mod tests { }), 6 ); - assert_eq!(tag(&ClientMessage::InputEvents { events: Vec::new() }), 7); assert_eq!( tag(&ClientMessage::ObserveTerminal { target: "w1:p1".to_owned(), }), - 8 + 7 ); assert_eq!( tag(&ClientMessage::ControlTerminal { target: "w1:p1".to_owned(), takeover: false, }), - 9 + 8 ); - } - - #[test] - fn client_input_events_roundtrip() { - let msg = ClientMessage::InputEvents { - events: vec![ - ClientInputEvent::Key { - code: ClientKeyCode::Char('N'), - modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), - kind: ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }, - ClientInputEvent::Key { - code: ClientKeyCode::Backspace, - modifiers: 0, - kind: ClientKeyKind::Press, - repeat_count: 3, - generated_text: None, - source: crate::protocol::ClientKeySource::Vt { - bytes: b"\x1b[127;1u".to_vec(), - }, - }, - ClientInputEvent::Key { - code: ClientKeyCode::Esc, - modifiers: 0, - kind: ClientKeyKind::Release, - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::WindowsConsole { - record: crate::input::WindowsKeyRecord { - key_down: false, - repeat_count: 1, - virtual_key_code: 27, - virtual_scan_code: 1, - unicode: 27, - control_key_state: 0, - }, - }, - }, - ClientInputEvent::TextCommit("你🙂".to_owned()), - ClientInputEvent::Mouse { - kind: ClientMouseKind::Down(ClientMouseButton::Left), - column: 3, - row: 4, - modifiers: 0, - }, - ], - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - // Freeze the protocol 20 input envelope before it is published. assert_eq!( - encoded, - vec![ - 7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 0, 0, 0, 3, 0, 1, 8, 27, 91, 49, 50, 55, 59, 49, - 117, 0, 14, 0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153, - 130, 2, 0, 0, 3, 4, 0, - ] + tag(&ClientMessage::AttachMouse { + kind: ClientMouseKind::Down(ClientMouseButton::Left), + position: ClientMousePosition::Cell { column: 10, row: 5 }, + geometry: None, + modifiers: 0, + lines: 1, + }), + 16 + ); + assert_eq!( + tag(&ClientMessage::ClientShellHostTheme { + update: ClientHostThemeUpdate::Appearance(ClientHostAppearance::Dark), + }), + 17 + ); + assert_eq!(tag(&ClientMessage::ClientShellFocus { focused: true }), 18); + assert_eq!( + tag(&ClientMessage::ClientShellMouseCapture { enabled: true }), + 19 ); - let (decoded, _): (ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); } #[test] @@ -1751,6 +1800,8 @@ mod tests { repeat_count: 1, shifted_codepoint: Some('L' as u32), generated_text: None, + tracks_release: true, + physical_key_id: None, }], }; let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()) @@ -1769,6 +1820,84 @@ mod tests { assert_eq!(key.kind, crossterm::event::KeyEventKind::Release); } + #[test] + fn client_shell_key_roundtrip_preserves_physical_generated_text_encoding() { + let key = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('/'), + crossterm::event::KeyModifiers::SHIFT, + ) + .with_generated_text(Some("/".into())) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x37, + virtual_scan_code: 0x08, + unicode: '/' as u16, + control_key_state: 0, + }); + let event = ClientPaneInputEvent::from_terminal_key(key).expect("semantic pane key"); + assert!(matches!( + event, + ClientPaneInputEvent::Key { + tracks_release: true, + .. + } + )); + let crate::raw_input::RawInputEvent::Key(roundtripped) = event.to_raw_input_event() else { + panic!("pane key should remain a key"); + }; + + assert!(roundtripped.has_physical_identity()); + let encoded = crate::input::encode_terminal_key( + roundtripped, + crate::input::KeyboardProtocol::Kitty { flags: 8 }, + ); + assert_ne!(encoded, b"/"); + assert!(encoded.starts_with(b"\x1b[")); + } + + #[test] + fn client_shell_focus_roundtrip() { + let message = ClientMessage::ClientShellFocus { focused: false }; + let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()) + .expect("encode client shell focus"); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode client shell focus"); + assert_eq!(decoded, message); + } + + #[test] + fn client_shell_mouse_capture_roundtrip() { + let message = ClientMessage::ClientShellMouseCapture { enabled: false }; + let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()) + .expect("encode client shell mouse capture"); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode client shell mouse capture"); + assert_eq!(decoded, message); + } + + #[test] + fn client_shell_host_theme_roundtrip() { + let message = ClientMessage::ClientShellHostTheme { + update: ClientHostThemeUpdate::PaletteColors(vec![( + 4, + ClientHostColor { + r: 10, + g: 20, + b: 30, + }, + )]), + }; + let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()) + .expect("encode host theme update"); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode host theme update"); + assert_eq!(decoded, message); + } + #[test] fn client_shell_endpoint_messages_roundtrip() { let request = ClientMessage::ClientShellEndpointRequest { @@ -1795,83 +1924,10 @@ mod tests { .expect("decode endpoint response"); assert_eq!(decoded, response); } - - #[test] - fn wire_release_cannot_restore_a_grouped_repeat_count() { - let event = ClientInputEvent::Key { - code: ClientKeyCode::Esc, - modifiers: 0, - kind: ClientKeyKind::Release, - repeat_count: 3, - generated_text: Some("ignored".to_owned()), - source: ClientKeySource::Synthesized, - }; - - match event.to_raw_input_event() { - crate::raw_input::RawInputEvent::Key(key) => { - assert_eq!(key.kind, crossterm::event::KeyEventKind::Release); - assert_eq!(key.repeat_count, 1); - assert_eq!(key.generated_text, None); - } - other => panic!("expected key event, got {other:?}"), - } - } - - #[test] - fn client_input_events_convert_to_raw_keys() { - let record = crate::input::WindowsKeyRecord { - key_down: false, - repeat_count: 1, - virtual_key_code: 78, - virtual_scan_code: 49, - unicode: 78, - control_key_state: 16, - }; - let shifted = ClientInputEvent::Key { - code: ClientKeyCode::Char('N'), - modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), - kind: ClientKeyKind::Press, - repeat_count: 1, - generated_text: None, - source: ClientKeySource::WindowsConsole { record }, - } - .to_raw_input_event(); - match shifted { - crate::raw_input::RawInputEvent::Key(key) => { - assert_eq!(key.code, crossterm::event::KeyCode::Char('N')); - assert_eq!(key.modifiers, crossterm::event::KeyModifiers::SHIFT); - assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); - assert_eq!( - key.windows_record().map(|record| record.key_down), - Some(false) - ); - } - other => panic!("expected shifted key event, got {other:?}"), - } - - let backspace = ClientInputEvent::Key { - code: ClientKeyCode::Backspace, - modifiers: 0, - kind: ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - } - .to_raw_input_event(); - match backspace { - crate::raw_input::RawInputEvent::Key(key) => { - assert_eq!(key.code, crossterm::event::KeyCode::Backspace); - assert_eq!(key.modifiers, crossterm::event::KeyModifiers::empty()); - assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); - } - other => panic!("expected backspace key event, got {other:?}"), - } - } - #[test] fn client_clipboard_image_roundtrip() { let msg = ClientMessage::ClipboardImage { + target: ClientClipboardImageTarget::Pane("w1:p1".into()), extension: "png".to_owned(), data: vec![0x89, b'P', b'N', b'G'], }; @@ -1905,6 +1961,7 @@ mod tests { rows: 24, cell_width_px: 8, cell_height_px: 16, + pixel_mouse: true, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = @@ -2065,17 +2122,28 @@ mod tests { hyperlinks: vec!["https://example.com".to_owned()], graphics: Vec::new(), }; - let msg = ServerMessage::Frame(frame.clone()); + let msg = ServerMessage::PaneSurface(PaneSurfaceFrame { + boot_id: "boot-1".into(), + projection_revision: 1, + frame: frame.clone(), + panes: Vec::new(), + splits: Vec::new(), + popup: None, + graphics: SurfaceGraphicsScene::default(), + }); let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); match decoded { - ServerMessage::Frame(frame) => { - assert_eq!(frame.cells[2].hyperlink, Some(0)); - assert_eq!(frame.hyperlinks, vec!["https://example.com".to_owned()]); + ServerMessage::PaneSurface(surface) => { + assert_eq!(surface.frame.cells[2].hyperlink, Some(0)); + assert_eq!( + surface.frame.hyperlinks, + vec!["https://example.com".to_owned()] + ); } - other => panic!("expected frame, got {other:?}"), + other => panic!("expected pane surface, got {other:?}"), } } @@ -2097,6 +2165,7 @@ mod tests { server_keybindings_toml: Some("[keys]\nprefix = \"ctrl+a\"\n".into()), latest_release_notes_available: true, integration_updates_available: true, + worktree_directory: "/tmp/herdr-worktrees".into(), release_notes: Some(ClientShellReleaseNotes { version: "0.8.3".into(), body: "### New\n- Update ready".into(), @@ -2280,8 +2349,20 @@ mod tests { } #[test] - fn server_kitty_keyboard_report_all_roundtrip() { - let msg = ServerMessage::KittyKeyboardReportAll { enabled: true }; + fn client_shell_keyboard_report_all_roundtrip() { + let msg = ServerMessage::ClientShellKeyboardReportAll { enabled: true }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn direct_terminal_keyboard_mode_roundtrip() { + let msg = ServerMessage::DirectTerminalKeyboardProtocol { + flags: 15, + modify_other_keys_level: 1, + }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); @@ -2313,29 +2394,6 @@ mod tests { let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(server, decoded); - - let pixels = ClientMessage::InputPixels { - data: b"\x1b[<35;321;241M".to_vec(), - cols: 80, - rows: 24, - width_px: 800, - height_px: 480, - }; - let encoded = bincode::serde::encode_to_vec(&pixels, bincode::config::standard()).unwrap(); - let (decoded, _): (ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(pixels, decoded); - } - - #[test] - fn server_prefix_input_source_roundtrip() { - for active in [true, false] { - let msg = ServerMessage::PrefixInputSource { active }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ServerMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } } #[test] @@ -2351,15 +2409,13 @@ mod tests { #[test] fn framing_small_message_roundtrip() { - let msg = ClientMessage::Hello { + let msg = ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: false, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -2369,7 +2425,7 @@ mod tests { #[test] fn framing_large_payload_roundtrip() { - // Create a Frame message that is ≥128 KB. + // Create a pane-surface message that is ≥128 KB. // Use a large frame with verbose cell data to exceed 128 KB after bincode encoding. // 200×50 = 10000 cells. With varied symbols and styles, this should easily exceed 128 KB. let width: u16 = 200; @@ -2402,7 +2458,15 @@ mod tests { hyperlinks: Vec::new(), graphics: Vec::new(), }; - let msg = ServerMessage::Frame(frame); + let msg = ServerMessage::PaneSurface(PaneSurfaceFrame { + boot_id: "boot-1".into(), + projection_revision: 1, + frame, + panes: Vec::new(), + splits: Vec::new(), + popup: None, + graphics: SurfaceGraphicsScene::default(), + }); let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -2425,20 +2489,19 @@ mod tests { for i in 0..150u32 { let msg = match i % 5 { - 0 => ClientMessage::Hello { + 0 => ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: (80 + (i % 40) as u16), rows: (24 + (i % 20) as u16), cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: i % 2 == 0, }, 1 => ClientMessage::Input { data: vec![(i % 256) as u8; (i as usize % 50) + 1], }, 2 => ClientMessage::ClipboardImage { + target: ClientClipboardImageTarget::DirectTerminal, extension: "png".to_owned(), data: vec![0x89, b'P', b'N', b'G', (i % 256) as u8], }, @@ -2447,6 +2510,7 @@ mod tests { rows: (30 + (i % 10) as u16), cell_width_px: 8, cell_height_px: 16, + pixel_mouse: i % 2 == 0, }, 4 => ClientMessage::Detach, _ => unreachable!(), @@ -2861,15 +2925,13 @@ mod tests { #[test] fn read_message_accepts_exact_payload() { // A normally-framed message should decode without error. - let msg = ClientMessage::Hello { + let msg = ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: false, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -2897,20 +2959,19 @@ mod tests { let (mut a, mut b) = UnixStream::pair().expect("socketpair"); let messages = vec![ - ClientMessage::Hello { + ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 200, rows: 60, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: true, }, ClientMessage::Input { data: b"hello world".to_vec(), }, ClientMessage::ClipboardImage { + target: ClientClipboardImageTarget::DirectTerminal, extension: "png".to_owned(), data: vec![0x89, b'P', b'N', b'G'], }, @@ -2919,6 +2980,7 @@ mod tests { rows: 30, cell_width_px: 8, cell_height_px: 16, + pixel_mouse: true, }, ClientMessage::Detach, ]; diff --git a/src/raw_input.rs b/src/raw_input.rs index 9da85ece..a1ae055c 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -4,6 +4,7 @@ use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; /// /// This directly extracts events without going through a channel, making it /// suitable for synchronous use. +#[cfg(any(unix, test))] pub fn parse_raw_input_bytes_sync(data: &[u8]) -> Vec { let mut framer = RawInputFramer::default(); let mut events = framer.push(data); @@ -518,6 +519,7 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> } } +#[cfg(any(unix, test))] pub(crate) fn events_require_host_surface_redraw( events: &[RawInputEvent], redraw_on_focus_gained: bool, diff --git a/src/selection.rs b/src/selection.rs index 35a97068..7290b8f0 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -56,23 +56,6 @@ impl

Selection

{ } } - /// Create an active selection from an explicit viewport-row range. - pub(crate) fn range( - pane_id: P, - viewport_row: u16, - start_col: u16, - end_col: u16, - metrics: Option, - ) -> Self { - let row = absolute_row_for_viewport_row(viewport_row, metrics); - Self { - pane_id, - anchor: (row, start_col), - cursor: (row, end_col), - phase: Phase::Dragging, - } - } - pub(crate) fn absolute_anchor(pane_id: P, anchor: (u32, u16)) -> Self { Self { pane_id, @@ -161,15 +144,11 @@ impl

Selection

{ } /// Whether this selection was already finalized. + #[cfg(test)] pub fn is_finalized(&self) -> bool { self.phase == Phase::Done } - /// Whether the user just clicked without dragging (not a selection). - pub fn was_just_click(&self) -> bool { - self.phase == Phase::Anchored - } - /// Whether the user just clicked without dragging (not a selection). pub fn is_just_click(&self) -> bool { self.phase == Phase::Anchored @@ -490,7 +469,7 @@ mod tests { #[test] fn click_without_drag() { let mut sel = Selection::anchor(PaneId::from_raw(0), 5, 10, None); - assert!(sel.was_just_click()); + assert!(sel.is_just_click()); let copied = sel.finish(); assert!(!copied); } @@ -500,7 +479,7 @@ mod tests { let mut sel = Selection::anchor(PaneId::from_raw(0), 5, 10, None); sel.drag(20, 7, Rect::new(10, 5, 80, 24), None); assert!(sel.is_visible()); - assert!(!sel.was_just_click()); + assert!(!sel.is_just_click()); let copied = sel.finish(); assert!(copied); } diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index 2b966ef7..1813ebd9 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -63,7 +63,7 @@ fn is_server_listening_at(socket_path: &Path) -> bool { Ok(_) => { // Server is listening. Close the test connection immediately. // The server's handshake handler will time out on this connection - // since we don't send Hello, which is fine. + // since we don't send a handshake, which is fine. true } Err(err) @@ -114,15 +114,13 @@ fn client_protocol_accepts_hello(socket_path: &Path) -> io::Result { Err(err) => return Err(err), }; - let hello = crate::protocol::ClientMessage::Hello { + let hello = crate::protocol::ClientMessage::TerminalHello { version: crate::protocol::PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, - requested_encoding: crate::protocol::RenderEncoding::SemanticFrame, - keybindings: crate::protocol::ClientKeybindings::Server, - launch_mode: crate::protocol::ClientLaunchMode::App, + pixel_mouse: false, }; match crate::protocol::write_message(&mut stream, &hello) { diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs index 6738f86a..eeb49c7e 100644 --- a/src/server/client_commands.rs +++ b/src/server/client_commands.rs @@ -26,6 +26,7 @@ pub(crate) fn supports_client_shell_method(method: &Method) -> bool { | Method::PaneFocus(_) | Method::PaneFocusDirection(_) | Method::PaneInputSet(_) + | Method::PaneLinkActivate(_) | Method::PaneRename(_) | Method::PaneResize(_) | Method::PaneScroll(_) @@ -140,6 +141,15 @@ mod tests { assert!(supports_client_shell_method(&Method::ServerReloadConfig( crate::api::schema::EmptyParams::default(), ))); + assert!(supports_client_shell_method(&Method::PaneLinkActivate( + crate::api::schema::PaneLinkActivateParams { + pane_id: "w1:p1".into(), + viewport_row: 0, + col: 0, + content_revision: None, + offset_from_bottom: None, + }, + ))); assert!(!supports_client_shell_method(&Method::Ping( crate::api::schema::PingParams::default(), ))); diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index d2e1c5c3..735ebc6f 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -181,6 +181,7 @@ pub(super) fn snapshot( server_keybindings_toml: app.client_shell_keybindings_profile().map(str::to_owned), latest_release_notes_available: app.state.latest_release_notes_available, integration_updates_available: app.state.integration_updates_available(), + worktree_directory: app.state.worktree_directory.to_string_lossy().into_owned(), release_notes, focused_workspace_id: snapshot.focused_workspace_id, focused_tab_id: snapshot.focused_tab_id, @@ -500,7 +501,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = crate::app::App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index ae3ce228..673e16d3 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -18,9 +18,9 @@ use tracing::{debug, warn}; use crate::ipc::LocalStream; use crate::protocol::{ - self, AttachScrollDirection, AttachScrollSource, ClientInputEvent, ClientKeybindings, - ClientLaunchMode, ClientMessage, ClientPaneInputEvent, RenderEncoding, ServerMessage, - MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, AttachScrollDirection, AttachScrollSource, ClientMessage, ClientPaneInputEvent, + RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; /// Minimum accepted attached client size. @@ -41,8 +41,6 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4); const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB /// Maximum structured input events accepted in one client message. const MAX_INPUT_EVENT_BATCH: usize = 4096; -/// Maximum encoded mouse report accepted with pixel geometry. -const MAX_PIXEL_MOUSE_PAYLOAD: usize = 128; /// Channels owned by the server side of a client writer thread. #[derive(Clone, Debug)] @@ -54,10 +52,6 @@ pub(crate) struct ClientWriter { } impl ClientWriter { - pub(crate) fn replace_with_cleanup(&self, data: Vec) { - self.render.queue.replace_with_cleanup(data); - } - #[cfg(test)] pub(crate) fn test_fill_render(&self, data: Vec) { self.render.try_send(data).unwrap(); @@ -254,16 +248,6 @@ impl ClientWriterQueue { Ok(()) } - fn replace_with_cleanup(&self, data: Vec) { - let mut state = self.lock_state(); - state.render = None; - state.ordered.clear(); - if state.writer_alive { - state.control.push_back(data); - self.ready.notify_one(); - } - } - fn recv(&self) -> Option { let mut state = self.lock_state(); loop { @@ -312,10 +296,7 @@ pub(crate) enum ServerEvent { rows: u16, cell_width_px: u32, cell_height_px: u32, - render_encoding: RenderEncoding, - keybindings: Option>, - direct_attach_requested: bool, - direct_graphics: bool, + pixel_mouse: bool, writer: ClientWriter, }, /// A client-owned shell completed its dedicated handshake. @@ -328,6 +309,7 @@ pub(crate) enum ServerEvent { pixel_mouse: bool, direct_graphics: bool, endpoint_keybindings: bool, + mouse_capture: bool, writer: ClientWriter, }, /// A client sent an input message. @@ -344,17 +326,6 @@ pub(crate) enum ServerEvent { transfer_id: u64, image_id: u32, }, - /// One confirmed SGR pixel report with client read-time geometry. - ClientInputPixels { - client_id: u64, - data: Vec, - geometry: crate::input::mouse::HostGeometry, - }, - /// A client sent structured input events. - ClientInputEvents { - client_id: u64, - events: Vec, - }, /// A fully decoded interactive paste exceeded the text-input limit. ClientPasteRejected { client_id: u64, @@ -364,6 +335,7 @@ pub(crate) enum ServerEvent { /// A client sent local clipboard image bytes to paste into a remote pane. ClientClipboardImage { client_id: u64, + target: crate::protocol::ClientClipboardImageTarget, extension: String, data: Vec, }, @@ -391,6 +363,15 @@ pub(crate) enum ServerEvent { row: Option, modifiers: u8, }, + /// A direct terminal attach client delivered one structured mouse event. + ClientAttachMouse { + client_id: u64, + kind: crate::protocol::ClientMouseKind, + position: crate::protocol::ClientMousePosition, + geometry: Option, + modifiers: u8, + lines: u16, + }, /// A client sent a resize message. ClientResize { client_id: u64, @@ -398,6 +379,7 @@ pub(crate) enum ServerEvent { rows: u16, cell_width_px: u32, cell_height_px: u32, + pixel_mouse: bool, }, /// A client-owned shell recomputed its pane viewport. ClientShellResize { @@ -406,6 +388,7 @@ pub(crate) enum ServerEvent { surface_rows: u16, cell_width_px: u32, cell_height_px: u32, + pixel_mouse: bool, }, /// A client-owned shell delivered semantic input to one stable pane target. ClientShellPaneInput { @@ -419,6 +402,15 @@ pub(crate) enum ServerEvent { terminal_id: String, events: Vec, }, + /// A client-owned shell published one host terminal theme observation. + ClientShellHostTheme { + client_id: u64, + update: crate::protocol::ClientHostThemeUpdate, + }, + /// A client-owned shell reported whether its outer terminal has focus. + ClientShellFocus { client_id: u64, focused: bool }, + /// A client-owned shell updated its local mouse-capture preference. + ClientShellMouseCapture { client_id: u64, enabled: bool }, /// A client-owned shell invoked one endpoint operation through this connection. ClientShellEndpointRequest { client_id: u64, @@ -450,23 +442,6 @@ pub(crate) fn clamp_terminal_size(cols: u16, rows: u16) -> (u16, u16) { (clamped_cols, clamped_rows) } -fn parse_client_keybindings( - keybindings: ClientKeybindings, -) -> Result>, String> { - match keybindings { - ClientKeybindings::Server => Ok(None), - ClientKeybindings::Local { keys_toml } => { - let mut config = toml::from_str::(&keys_toml) - .map_err(|err| format!("invalid client keybindings: {err}"))?; - config.keys.command.clear(); - Ok(Some(Box::new(crate::config::LiveKeybindConfig { - prefix: config.prefix_key(), - keybinds: config.keybinds(), - }))) - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InputEventLimit { WithinLimits, @@ -519,47 +494,6 @@ fn pane_input_event_limit(events: &[ClientPaneInputEvent]) -> InputEventLimit { classify_input_event_size(expanded_events, paste_bytes, input_bytes) } -fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit { - let mut expanded_events = 0usize; - let mut paste_bytes = 0usize; - let mut input_bytes = 0usize; - for event in events { - expanded_events = expanded_events.saturating_add(match event { - ClientInputEvent::Key { repeat_count, .. } => usize::from((*repeat_count).max(1)), - _ => 1, - }); - match event { - ClientInputEvent::Key { - repeat_count, - generated_text, - source, - .. - } => { - if let Some(text) = generated_text { - input_bytes = input_bytes.saturating_add( - text.len() - .saturating_mul(usize::from((*repeat_count).max(1))), - ); - } - if let crate::protocol::ClientKeySource::Vt { bytes } = source { - input_bytes = input_bytes.saturating_add(bytes.len()); - } - } - ClientInputEvent::TextCommit(text) => { - input_bytes = input_bytes.saturating_add(text.len()); - } - ClientInputEvent::Paste { text } => { - paste_bytes = paste_bytes.saturating_add(text.len()); - } - ClientInputEvent::Mouse { .. } - | ClientInputEvent::FocusGained - | ClientInputEvent::FocusLost => {} - } - } - - classify_input_event_size(expanded_events, paste_bytes, input_bytes) -} - fn classify_input_event_size( expanded_events: usize, paste_bytes: usize, @@ -612,8 +546,8 @@ fn set_client_recv_timeout( /// Handles the client handshake on a blocking thread. /// -/// Reads the `Hello` message, validates the version, sends `Welcome`, -/// and then enters a read loop forwarding messages to the server event channel. +/// Reads the `TerminalHello` or `ClientShellHello` message, validates the version, +/// sends `Welcome`, and then enters a read loop forwarding messages to the server event channel. pub(crate) fn handle_client_handshake( mut stream: LocalStream, client_id: u64, @@ -635,7 +569,7 @@ pub(crate) fn handle_client_handshake( client_id, )?; - // Read the Hello message. + // Read the handshake message. let hello: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) { Ok(msg) => msg, Err(protocol::FramingError::UnexpectedEof) => { @@ -657,73 +591,40 @@ pub(crate) fn handle_client_handshake( client_rows, cell_width_px, cell_height_px, - render_encoding, - keybindings, - direct_attach_requested, - direct_graphics, - pixel_mouse, - client_rendered_shell, - endpoint_keybindings, + terminal_pixel_mouse, + shell_options, ) = match hello { - ClientMessage::Hello { + ClientMessage::TerminalHello { version, cols, rows, cell_width_px, cell_height_px, - requested_encoding, - keybindings, - launch_mode, + pixel_mouse, } => { if let protocol::VersionCheck::Incompatible(reason) = protocol::check_client_version(version) { let welcome = ServerMessage::Welcome { version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, + encoding: RenderEncoding::TerminalAnsi, error: Some(reason), }; let _ = protocol::write_message(&mut stream, &welcome); return Ok(()); } - let keybindings = match parse_client_keybindings(keybindings) { - Ok(keybindings) => keybindings, - Err(error) => { - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some(error), - }; - let _ = protocol::write_message(&mut stream, &welcome); - return Ok(()); - } - }; let (cols, rows) = clamp_terminal_size(cols, rows); - ( - cols, - rows, - cell_width_px, - cell_height_px, - requested_encoding, - keybindings, - launch_mode == ClientLaunchMode::TerminalAttach, - launch_mode == ClientLaunchMode::AppDirectGraphics, - launch_mode == ClientLaunchMode::AppDirectGraphics, - false, - false, - ) + (cols, rows, cell_width_px, cell_height_px, pixel_mouse, None) } ClientMessage::ClientShellHello { version, - cols, - rows, cell_width_px, cell_height_px, - requested_encoding, surface_size, pixel_mouse, direct_graphics, endpoint_keybindings, + mouse_capture, } => { if let protocol::VersionCheck::Incompatible(reason) = protocol::check_client_version(version) @@ -736,33 +637,27 @@ pub(crate) fn handle_client_handshake( let _ = protocol::write_message(&mut stream, &welcome); return Ok(()); } - if requested_encoding != RenderEncoding::SemanticFrame - || surface_size.cols == 0 - || surface_size.rows == 0 - { + if surface_size.cols == 0 || surface_size.rows == 0 { let welcome = ServerMessage::Welcome { version: PROTOCOL_VERSION, encoding: RenderEncoding::SemanticFrame, - error: Some( - "client shell requires semantic encoding and a non-empty pane surface" - .to_owned(), - ), + error: Some("client shell requires a non-empty pane surface".to_owned()), }; let _ = protocol::write_message(&mut stream, &welcome); return Ok(()); } ( - surface_size.cols.min(cols.max(1)), - surface_size.rows.min(rows.max(1)), + surface_size.cols, + surface_size.rows, cell_width_px, cell_height_px, - requested_encoding, - None, false, - direct_graphics, - pixel_mouse, - true, - endpoint_keybindings, + Some(( + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + )), ) } _ => { @@ -770,7 +665,9 @@ pub(crate) fn handle_client_handshake( let welcome = ServerMessage::Welcome { version: PROTOCOL_VERSION, encoding: RenderEncoding::SemanticFrame, - error: Some("expected Hello as first message".to_owned()), + error: Some( + "expected TerminalHello or ClientShellHello as first message".to_owned(), + ), }; let _ = protocol::write_message(&mut stream, &welcome); return Ok(()); @@ -782,6 +679,11 @@ pub(crate) fn handle_client_handshake( } // Send Welcome. + let render_encoding = if shell_options.is_some() { + RenderEncoding::SemanticFrame + } else { + RenderEncoding::TerminalAnsi + }; let welcome = ServerMessage::Welcome { version: PROTOCOL_VERSION, encoding: render_encoding, @@ -816,32 +718,33 @@ pub(crate) fn handle_client_handshake( } // Notify the main loop about the new client. - let connected = if client_rendered_shell { - ServerEvent::ClientShellConnected { - client_id, - surface_cols: client_cols, - surface_rows: client_rows, - cell_width_px, - cell_height_px, - pixel_mouse, - direct_graphics, - endpoint_keybindings, - writer, - } - } else { - ServerEvent::ClientConnected { - client_id, - cols: client_cols, - rows: client_rows, - cell_width_px, - cell_height_px, - render_encoding, - keybindings, - direct_attach_requested, - direct_graphics, - writer, - } - }; + let connected = + if let Some((pixel_mouse, direct_graphics, endpoint_keybindings, mouse_capture)) = + shell_options + { + ServerEvent::ClientShellConnected { + client_id, + surface_cols: client_cols, + surface_rows: client_rows, + cell_width_px, + cell_height_px, + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + writer, + } + } else { + ServerEvent::ClientConnected { + client_id, + cols: client_cols, + rows: client_rows, + cell_width_px, + cell_height_px, + pixel_mouse: terminal_pixel_mouse, + writer, + } + }; if let Err(err) = server_event_tx.blocking_send(connected) { match err.0 { ServerEvent::ClientConnected { writer, .. } @@ -971,86 +874,6 @@ fn client_read_loop( ServerEvent::ClientInput { client_id, data } } } - ClientMessage::InputPixels { - data, - cols, - rows, - width_px, - height_px, - } => { - let Some(geometry) = - crate::input::mouse::HostGeometry::new(cols, rows, width_px, height_px) - else { - warn!( - client_id, - cols, - rows, - width_px, - height_px, - "invalid pixel mouse geometry from client, closing" - ); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; - }; - if data.len() > MAX_PIXEL_MOUSE_PAYLOAD - || crate::input::mouse::parse_report(&data).is_none() - { - warn!( - client_id, - size = data.len(), - max = MAX_PIXEL_MOUSE_PAYLOAD, - "invalid pixel mouse report from client, closing" - ); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; - } - ServerEvent::ClientInputPixels { - client_id, - data, - geometry, - } - } - ClientMessage::InputEvents { events } => match input_event_limit(&events) { - InputEventLimit::WithinLimits => { - ServerEvent::ClientInputEvents { client_id, events } - } - InputEventLimit::TooManyEvents => { - warn!( - client_id, - count = events.len(), - "oversized input event batch from client, closing" - ); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; - } - InputEventLimit::PasteTooLarge { size } => { - warn!( - client_id, - size, - max = MAX_INPUT_PAYLOAD, - "oversized structured paste from client, rejecting" - ); - ServerEvent::ClientPasteRejected { - client_id, - size, - max: MAX_INPUT_PAYLOAD, - } - } - InputEventLimit::InputPayloadTooLarge { size } => { - warn!( - client_id, - size, - max = MAX_INPUT_PAYLOAD, - "oversized structured input payload from client, closing" - ); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; - } - }, ClientMessage::ObserveTerminal { target } => { ServerEvent::ClientObserveTerminal { client_id, target } } @@ -1079,7 +902,11 @@ fn client_read_loop( transfer_id, image_id, }, - ClientMessage::ClipboardImage { extension, data } => { + ClientMessage::ClipboardImage { + target, + extension, + data, + } => { if data.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { warn!( client_id, @@ -1092,6 +919,7 @@ fn client_read_loop( } else { ServerEvent::ClientClipboardImage { client_id, + target, extension, data, } @@ -1102,6 +930,7 @@ fn client_read_loop( rows, cell_width_px, cell_height_px, + pixel_mouse, } => { let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); ServerEvent::ClientResize { @@ -1110,21 +939,41 @@ fn client_read_loop( rows: clamped_rows, cell_width_px, cell_height_px, + pixel_mouse, } } ClientMessage::ClientShellResize { - cols, - rows, cell_width_px, cell_height_px, surface_size, + pixel_mouse, } => ServerEvent::ClientShellResize { client_id, - surface_cols: surface_size.cols.max(1).min(cols.max(1)), - surface_rows: surface_size.rows.max(1).min(rows.max(1)), + surface_cols: surface_size.cols.max(1), + surface_rows: surface_size.rows.max(1), cell_width_px, cell_height_px, + pixel_mouse, }, + ClientMessage::ClientShellHostTheme { update } => { + if matches!( + &update, + crate::protocol::ClientHostThemeUpdate::PaletteColors(colors) + if colors.len() > 256 + ) { + warn!(client_id, "invalid client shell host theme update, closing"); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + ServerEvent::ClientShellHostTheme { client_id, update } + } + ClientMessage::ClientShellFocus { focused } => { + ServerEvent::ClientShellFocus { client_id, focused } + } + ClientMessage::ClientShellMouseCapture { enabled } => { + ServerEvent::ClientShellMouseCapture { client_id, enabled } + } ClientMessage::ClientShellPaneInput { pane_id, events } => { match pane_input_event_limit(&events) { InputEventLimit::WithinLimits => ServerEvent::ClientShellPaneInput { @@ -1249,7 +1098,10 @@ fn client_read_loop( request: Box::new(request), } } - ClientMessage::Detach => ServerEvent::ClientDetach { client_id }, + ClientMessage::Detach => { + let _ = server_event_tx.blocking_send(ServerEvent::ClientDetach { client_id }); + break; + } ClientMessage::AttachTerminal { terminal_id, takeover, @@ -1274,7 +1126,21 @@ fn client_read_loop( row, modifiers, }, - ClientMessage::Hello { .. } | ClientMessage::ClientShellHello { .. } => { + ClientMessage::AttachMouse { + kind, + position, + geometry, + modifiers, + lines, + } => ServerEvent::ClientAttachMouse { + client_id, + kind, + position, + geometry, + modifiers, + lines, + }, + ClientMessage::TerminalHello { .. } | ClientMessage::ClientShellHello { .. } => { // Duplicate handshake — ignore. continue; } @@ -1565,54 +1431,6 @@ mod tests { ); } - #[test] - fn parse_client_keybindings_accepts_local_profile() { - let keybindings = parse_client_keybindings(ClientKeybindings::Local { - keys_toml: r#" -[keys] -prefix = "ctrl+a" -new_tab = "prefix+t" - -[[keys.command]] -key = "prefix+g" -command = "lazygit" -"# - .to_owned(), - }) - .expect("valid client keybindings") - .expect("local profile"); - - assert_eq!(keybindings.prefix.0, crossterm::event::KeyCode::Char('a')); - assert!(keybindings - .keybinds - .new_tab - .bindings - .iter() - .any(|binding| binding.label == "prefix+t")); - assert!(keybindings.keybinds.custom_commands.is_empty()); - } - - #[test] - fn parse_client_keybindings_tolerates_disabled_bindings() { - let keybindings = parse_client_keybindings(ClientKeybindings::Local { - keys_toml: r#" -[keys] -new_tab = "ctrl+notakey" -"# - .to_owned(), - }) - .expect("diagnostic-only client keybindings should be accepted") - .expect("local profile"); - - assert!(keybindings.keybinds.new_tab.bindings.is_empty()); - assert!(keybindings - .keybinds - .next_tab - .bindings - .iter() - .any(|binding| binding.label == "prefix+n")); - } - #[test] fn handshake_negotiates_terminal_ansi_encoding() { let (mut client_stream, server_stream, _path) = local_stream_pair("client-handshake-ansi"); @@ -1625,15 +1443,13 @@ new_tab = "ctrl+notakey" protocol::write_message( &mut client_stream, - &ClientMessage::Hello { + &ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 100, rows: 30, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::TerminalAnsi, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: true, }, ) .expect("write hello"); @@ -1663,19 +1479,13 @@ new_tab = "ctrl+notakey" rows, cell_width_px, cell_height_px, - render_encoding, - keybindings, - direct_attach_requested, - direct_graphics, + pixel_mouse, writer, } => { assert_eq!(client_id, 42); assert_eq!((cols, rows), (100, 30)); assert_eq!((cell_width_px, cell_height_px), (8, 16)); - assert_eq!(render_encoding, RenderEncoding::TerminalAnsi); - assert!(keybindings.is_none()); - assert!(!direct_attach_requested); - assert!(!direct_graphics); + assert!(pixel_mouse); drop(writer); } other => panic!("expected ClientConnected, got {other:?}"), @@ -1703,15 +1513,13 @@ new_tab = "ctrl+notakey" &mut client_stream, &ClientMessage::ClientShellHello { version: PROTOCOL_VERSION, - cols: 106, - rows: 30, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 29 }, pixel_mouse: true, direct_graphics: true, endpoint_keybindings: true, + mouse_capture: true, }, ) .expect("write shell hello"); @@ -1739,6 +1547,7 @@ new_tab = "ctrl+notakey" pixel_mouse, direct_graphics, endpoint_keybindings, + mouse_capture, writer, } => { assert_eq!(client_id, 43); @@ -1747,6 +1556,7 @@ new_tab = "ctrl+notakey" assert!(pixel_mouse); assert!(direct_graphics); assert!(endpoint_keybindings); + assert!(mouse_capture); drop(writer); } other => panic!("expected ClientShellConnected, got {other:?}"), @@ -1761,67 +1571,82 @@ new_tab = "ctrl+notakey" } #[test] - fn handshake_marks_terminal_attach_launch_mode() { + fn dedicated_client_shell_handshake_rejects_empty_surface() { let (mut client_stream, server_stream, _path) = - local_stream_pair("client-handshake-terminal-attach"); + local_stream_pair("client-shell-empty-surface"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); let should_quit = Arc::new(AtomicBool::new(false)); let handshake_quit = should_quit.clone(); let handle = std::thread::spawn(move || { - handle_client_handshake(server_stream, 42, &server_event_tx, &handshake_quit) + handle_client_handshake(server_stream, 43, &server_event_tx, &handshake_quit) }); protocol::write_message( &mut client_stream, - &ClientMessage::Hello { + &ClientMessage::ClientShellHello { version: PROTOCOL_VERSION, - cols: 100, - rows: 30, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::TerminalAnsi, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::TerminalAttach, + surface_size: crate::protocol::ClientSurfaceSize { cols: 0, rows: 29 }, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, }, ) - .expect("write hello"); + .expect("write empty shell hello"); let welcome: ServerMessage = protocol::read_message(&mut client_stream, MAX_FRAME_SIZE).expect("read welcome"); - match welcome { + assert!(matches!( + welcome, ServerMessage::Welcome { - version, - encoding, - error, - } => { - assert_eq!(version, PROTOCOL_VERSION); - assert_eq!(encoding, RenderEncoding::TerminalAnsi); - assert_eq!(error, None); - } - other => panic!("expected Welcome, got {other:?}"), - } - - match server_event_rx - .blocking_recv() - .expect("client connected event") - { - ServerEvent::ClientConnected { - direct_attach_requested, - writer, + encoding: RenderEncoding::SemanticFrame, + error: Some(error), .. - } => { - assert!(direct_attach_requested); - drop(writer); - } - other => panic!("expected ClientConnected, got {other:?}"), - } - - drop(client_stream); - should_quit.store(true, Ordering::Release); + } if error.contains("non-empty pane surface") + )); handle .join() .expect("handshake thread join") .expect("handshake thread result"); + assert!(server_event_rx.try_recv().is_err()); + } + + #[test] + fn client_read_loop_stops_after_detach() { + let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-detach"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let read_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + client_read_loop(server_stream, 7, &server_event_tx, &read_quit) + }); + + let mut messages = Vec::new(); + protocol::write_message(&mut messages, &ClientMessage::Detach).unwrap(); + protocol::write_message( + &mut messages, + &ClientMessage::ClipboardImage { + target: crate::protocol::ClientClipboardImageTarget::DirectTerminal, + extension: "png".into(), + data: vec![1, 2, 3], + }, + ) + .unwrap(); + client_stream + .write_all(&messages) + .expect("write detach and trailing message"); + + assert!(matches!( + recv_server_event(&mut server_event_rx, "detach event"), + ServerEvent::ClientDetach { client_id: 7 } + )); + handle + .join() + .expect("read thread join") + .expect("read thread result"); + assert!(server_event_rx.try_recv().is_err()); } #[test] @@ -1930,76 +1755,6 @@ new_tab = "ctrl+notakey" .expect("read thread result"); } - #[test] - fn client_read_loop_disconnects_invalid_pixel_mouse_geometry() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-read-invalid-pixel-geometry"); - let (server_event_tx, mut server_event_rx) = mpsc::channel(4); - let should_quit = Arc::new(AtomicBool::new(false)); - let read_quit = should_quit.clone(); - let handle = std::thread::spawn(move || { - client_read_loop(server_stream, 7, &server_event_tx, &read_quit) - }); - - protocol::write_message( - &mut client_stream, - &ClientMessage::InputPixels { - data: b"\x1b[<35;1;1M".to_vec(), - cols: 0, - rows: 24, - width_px: 800, - height_px: 480, - }, - ) - .expect("write invalid pixel geometry"); - - assert!(matches!( - recv_server_event(&mut server_event_rx, "invalid pixel geometry disconnect"), - ServerEvent::ClientDisconnected { client_id: 7 } - )); - drop(client_stream); - should_quit.store(true, Ordering::Release); - handle - .join() - .expect("read thread join") - .expect("read thread result"); - } - - #[test] - fn client_read_loop_disconnects_invalid_pixel_mouse_report() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-read-invalid-pixel-report"); - let (server_event_tx, mut server_event_rx) = mpsc::channel(4); - let should_quit = Arc::new(AtomicBool::new(false)); - let read_quit = should_quit.clone(); - let handle = std::thread::spawn(move || { - client_read_loop(server_stream, 7, &server_event_tx, &read_quit) - }); - - protocol::write_message( - &mut client_stream, - &ClientMessage::InputPixels { - data: vec![b'x'; MAX_PIXEL_MOUSE_PAYLOAD + 1], - cols: 80, - rows: 24, - width_px: 800, - height_px: 480, - }, - ) - .expect("write invalid pixel report"); - - assert!(matches!( - recv_server_event(&mut server_event_rx, "invalid pixel report disconnect"), - ServerEvent::ClientDisconnected { client_id: 7 } - )); - drop(client_stream); - should_quit.store(true, Ordering::Release); - handle - .join() - .expect("read thread join") - .expect("read thread result"); - } - #[test] fn client_read_loop_disconnects_marker_wrapped_invalid_utf8() { let (mut client_stream, server_stream, _path) = @@ -2030,48 +1785,116 @@ new_tab = "ctrl+notakey" } #[test] - fn client_read_loop_forwards_input_events() { - let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-events"); + fn client_read_loop_uses_authoritative_shell_resize_surface() { + let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-resize"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); let should_quit = Arc::new(AtomicBool::new(false)); let read_quit = should_quit.clone(); let handle = std::thread::spawn(move || { client_read_loop(server_stream, 7, &server_event_tx, &read_quit) }); - let events = vec![ - ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Enter, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }, - ClientInputEvent::FocusGained, - ]; protocol::write_message( &mut client_stream, - &ClientMessage::InputEvents { - events: events.clone(), + &ClientMessage::ClientShellResize { + cell_width_px: 8, + cell_height_px: 16, + surface_size: crate::protocol::ClientSurfaceSize { cols: 60, rows: 15 }, + pixel_mouse: true, }, ) - .expect("write input events"); - - match server_event_rx - .blocking_recv() - .expect("client input events event") - { - ServerEvent::ClientInputEvents { - client_id, - events: actual, - } => { - assert_eq!(client_id, 7); - assert_eq!(actual, events); + .expect("write shell resize"); + assert!(matches!( + recv_server_event(&mut server_event_rx, "shell resize"), + ServerEvent::ClientShellResize { + client_id: 7, + surface_cols: 60, + surface_rows: 15, + cell_width_px: 8, + cell_height_px: 16, + pixel_mouse: true, } - other => panic!("expected ClientInputEvents, got {other:?}"), - } + )); + + protocol::write_message(&mut client_stream, &ClientMessage::Detach).expect("write detach"); + assert!(matches!( + recv_server_event(&mut server_event_rx, "detach event"), + ServerEvent::ClientDetach { client_id: 7 } + )); + handle + .join() + .expect("read thread join") + .expect("read thread result"); + } + + #[test] + fn client_read_loop_keeps_single_host_theme_updates_ordered_and_palette_bounded() { + let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-host-theme"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let read_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + client_read_loop(server_stream, 7, &server_event_tx, &read_quit) + }); + + let colors = (0..=u8::MAX) + .map(|index| { + ( + index, + crate::protocol::ClientHostColor { + r: index, + g: 0, + b: 0, + }, + ) + }) + .collect(); + protocol::write_message( + &mut client_stream, + &ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::PaletteColors(colors), + }, + ) + .expect("write bounded palette update"); + protocol::write_message( + &mut client_stream, + &ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::Appearance( + crate::protocol::ClientHostAppearance::Dark, + ), + }, + ) + .expect("write ordered appearance update"); + + assert!(matches!( + recv_server_event(&mut server_event_rx, "bounded palette update"), + ServerEvent::ClientShellHostTheme { + client_id: 7, + update: crate::protocol::ClientHostThemeUpdate::PaletteColors(colors), + } if colors.len() == 256 + )); + assert!(matches!( + recv_server_event(&mut server_event_rx, "ordered appearance update"), + ServerEvent::ClientShellHostTheme { + client_id: 7, + update: crate::protocol::ClientHostThemeUpdate::Appearance( + crate::protocol::ClientHostAppearance::Dark + ), + } + )); + + let colors = vec![(0, crate::protocol::ClientHostColor { r: 0, g: 0, b: 0 },); 257]; + protocol::write_message( + &mut client_stream, + &ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::PaletteColors(colors), + }, + ) + .expect("write oversized palette update"); + assert!(matches!( + recv_server_event(&mut server_event_rx, "oversized palette disconnect"), + ServerEvent::ClientDisconnected { client_id: 7 } + )); drop(client_stream); should_quit.store(true, Ordering::Release); @@ -2082,149 +1905,11 @@ new_tab = "ctrl+notakey" } #[test] - fn client_read_loop_rejects_oversized_input_event_batch() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-read-oversized-events"); - let (server_event_tx, mut server_event_rx) = mpsc::channel(4); - let should_quit = Arc::new(AtomicBool::new(false)); - let read_quit = should_quit.clone(); - let handle = std::thread::spawn(move || { - client_read_loop(server_stream, 7, &server_event_tx, &read_quit) - }); - - protocol::write_message( - &mut client_stream, - &ClientMessage::InputEvents { - events: vec![ClientInputEvent::FocusGained; MAX_INPUT_EVENT_BATCH + 1], - }, - ) - .expect("write oversized input events"); - - match server_event_rx - .blocking_recv() - .expect("client disconnected event") - { - ServerEvent::ClientDisconnected { client_id } => assert_eq!(client_id, 7), - other => panic!("expected ClientDisconnected, got {other:?}"), - } - - drop(client_stream); - should_quit.store(true, Ordering::Release); - handle - .join() - .expect("read thread join") - .expect("read thread result"); - } - - #[test] - fn client_read_loop_rejects_oversized_input_event_paste() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-read-oversized-paste"); - let (server_event_tx, mut server_event_rx) = mpsc::channel(4); - let should_quit = Arc::new(AtomicBool::new(false)); - let read_quit = should_quit.clone(); - let handle = std::thread::spawn(move || { - client_read_loop(server_stream, 7, &server_event_tx, &read_quit) - }); - - let maximum = vec![ - ClientInputEvent::Paste { - text: "x".repeat(MAX_INPUT_PAYLOAD / 2), - }, - ClientInputEvent::Paste { - text: "y".repeat(MAX_INPUT_PAYLOAD - (MAX_INPUT_PAYLOAD / 2)), - }, - ]; - protocol::write_message( - &mut client_stream, - &ClientMessage::InputEvents { - events: maximum.clone(), - }, - ) - .expect("write maximum-size structured paste"); - - match recv_server_event(&mut server_event_rx, "maximum-size structured paste") { - ServerEvent::ClientInputEvents { client_id, events } => { - assert_eq!(client_id, 7); - assert_eq!(events, maximum); - } - other => panic!("expected maximum-size ClientInputEvents, got {other:?}"), - } - - let oversized = vec![ - ClientInputEvent::FocusGained, - ClientInputEvent::Paste { - text: "x".repeat(MAX_INPUT_PAYLOAD / 2), - }, - ClientInputEvent::Paste { - text: "y".repeat(MAX_INPUT_PAYLOAD - (MAX_INPUT_PAYLOAD / 2) + 1), - }, - ClientInputEvent::FocusLost, - ClientInputEvent::Paste { - text: "tail".to_owned(), - }, - ]; - protocol::write_message( - &mut client_stream, - &ClientMessage::InputEvents { events: oversized }, - ) - .expect("write oversized structured paste"); - - match recv_server_event(&mut server_event_rx, "oversized structured paste rejection") { - ServerEvent::ClientPasteRejected { - client_id, - size, - max, - } => { - assert_eq!(client_id, 7); - assert_eq!(size, MAX_INPUT_PAYLOAD + 5); - assert_eq!(max, MAX_INPUT_PAYLOAD); - } - other => panic!("expected ClientPasteRejected, got {other:?}"), - } - - let valid = vec![ClientInputEvent::FocusGained]; - protocol::write_message( - &mut client_stream, - &ClientMessage::InputEvents { - events: valid.clone(), - }, - ) - .expect("write valid structured input after rejection"); - - match recv_server_event(&mut server_event_rx, "structured input after rejection") { - ServerEvent::ClientInputEvents { client_id, events } => { - assert_eq!(client_id, 7); - assert_eq!(events, valid); - } - other => panic!("expected ClientInputEvents after rejection, got {other:?}"), - } - - drop(client_stream); - should_quit.store(true, Ordering::Release); - handle - .join() - .expect("read thread join") - .expect("read thread result"); - } - - #[test] - fn structured_input_limits_charge_grouped_repeats_and_text_payloads() { - let grouped = ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: (MAX_INPUT_EVENT_BATCH + 1) as u16, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }; - assert_eq!( - input_event_limit(&[grouped]), - InputEventLimit::TooManyEvents - ); + fn pane_input_limits_charge_scroll_repeats() { let oversized_scroll = ClientPaneInputEvent::Mouse { kind: crate::protocol::ClientMouseKind::ScrollUp, position: crate::protocol::ClientMousePosition::Cell { column: 0, row: 0 }, + geometry: None, modifiers: 0, lines: (MAX_INPUT_EVENT_BATCH + 1) as u16, }; @@ -2232,27 +1917,6 @@ new_tab = "ctrl+notakey" pane_input_event_limit(&[oversized_scroll]), InputEventLimit::TooManyEvents ); - - let repeated_text = ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: MAX_INPUT_EVENT_BATCH as u16, - generated_text: Some("x".repeat((MAX_INPUT_PAYLOAD / MAX_INPUT_EVENT_BATCH) + 1)), - source: crate::protocol::ClientKeySource::Synthesized, - }; - assert!(matches!( - input_event_limit(&[repeated_text]), - InputEventLimit::InputPayloadTooLarge { size } if size > MAX_INPUT_PAYLOAD - )); - - let text = ClientInputEvent::TextCommit("x".repeat(MAX_INPUT_PAYLOAD + 1)); - assert_eq!( - input_event_limit(&[text]), - InputEventLimit::InputPayloadTooLarge { - size: MAX_INPUT_PAYLOAD + 1 - } - ); } #[test] diff --git a/src/server/clients.rs b/src/server/clients.rs index aa34fda8..d380e2c3 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -1,14 +1,17 @@ use std::collections::HashMap; use std::path::PathBuf; -use crate::protocol::RenderEncoding; +use crate::protocol::{ + ClientKeyCode, ClientKeyKind, ClientMouseButton, ClientMouseKind, ClientPaneInputEvent, + RenderEncoding, +}; use crate::server::client_transport::ClientWriter; use crate::server::render_stream::ClientRenderState; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ClientConnectionMode { - App, ClientShell, + TerminalPending, TerminalAttach { terminal_id: String }, TerminalObserve { terminal_id: String }, } @@ -21,6 +24,35 @@ pub(crate) type RenderTarget = ( ClientConnectionMode, ); +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ClientShellInputTarget { + Pane(String), + Popup(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum ClientShellPressId { + PhysicalKey(u32), + SemanticKey(ClientKeyCode), + Mouse(ClientMouseButton), +} + +fn client_shell_key_press_id( + code: &ClientKeyCode, + physical_key_id: Option, +) -> ClientShellPressId { + physical_key_id.map_or_else( + || ClientShellPressId::SemanticKey(code.clone()), + ClientShellPressId::PhysicalKey, + ) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ClientShellHeldInput { + pub(crate) target: ClientShellInputTarget, + pub(crate) release: ClientPaneInputEvent, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum DeferredRender { #[default] @@ -30,48 +62,44 @@ pub(crate) enum DeferredRender { /// A connected client tracked by the server. pub(crate) struct ClientConnection { - /// Whether this connection is the full app client or a direct terminal attach. + /// Whether this connection owns the Herdr shell or one direct terminal stream. pub(crate) mode: ClientConnectionMode, - /// True after the handshake for clients that will switch into direct terminal attach mode. - pub(crate) pending_terminal_attach: bool, - /// Client-local app keybindings. None means use the server's keybindings. - pub(crate) keybindings: Option>, /// The client's terminal size after clamping. pub(crate) terminal_size: (u16, u16), /// Pixel size of one client terminal cell. pub(crate) cell_size: crate::kitty_graphics::HostCellSize, - /// Last known host terminal default colors for this client. - pub(crate) host_terminal_theme: crate::terminal_theme::TerminalTheme, - /// Last known host terminal appearance for this client. - pub(crate) host_terminal_appearance: Option, - /// True when appearance came from an explicit host color-scheme report. - pub(crate) host_terminal_appearance_explicit: bool, - /// Last reported focus state for this client's outer terminal. - pub(crate) outer_terminal_focus: Option, - /// Stateful parser for app-client input split across transport reads. - pub(crate) raw_input: crate::raw_input::RawInputFramer, /// Monotonic activity stamp used to choose the fallback foreground client. pub(crate) last_activity: u64, /// Render baseline for the negotiated client encoding. pub(crate) render_state: ClientRenderState, - /// Client-local host Kitty graphics cache for the legacy server-rendered app path. - pub(crate) graphics_cache: crate::kitty_graphics::HostGraphicsCache, /// Image assets already included in the selected ClientShell scene. pub(crate) shell_graphics_delivery: crate::kitty_graphics::surface::DeliveryCache, /// Passive eligibility for audited local Kitty regular-file graphics. pub(crate) direct_graphics: bool, /// Whether this frontend preserves exact SGR pixel reports. pub(crate) pixel_mouse: bool, - /// Whether the next graphics frame must clear and rebuild host-side Kitty state. - pub(crate) graphics_surface_reset_pending: bool, + /// Last host terminal default colors reported by this client. + pub(crate) host_terminal_theme: crate::terminal_theme::TerminalTheme, + /// Last host light/dark appearance reported by this client. + pub(crate) host_terminal_appearance: Option, + /// Whether appearance came from an explicit host color-scheme report. + pub(crate) host_terminal_appearance_explicit: bool, + /// Last reported focus state for this client's outer terminal. + pub(crate) outer_terminal_focus: Option, + /// Last focused-pane report-all demand sent to a client-owned shell. + pub(crate) host_keyboard_report_all_active: Option, /// Whether an ordinary render was skipped because the render channel was full. pub(crate) render_pending: bool, + /// Whether this shell wants host mouse capture without pane demand. + pub(crate) shell_mouse_capture: bool, /// Last host mouse capture mode sent to this client. pub(crate) host_mouse_capture_active: Option, /// Last SGR pixel provenance mode sent to this client. pub(crate) host_sgr_pixels_active: Option, - /// Last Kitty report-all mode sent to this client's host terminal. - pub(crate) host_keyboard_report_all_active: Option, + /// Last keyboard protocol state sent to a directly attached terminal client. + pub(crate) host_keyboard_protocol_active: Option<(u16, u8)>, + /// Presses forwarded by this shell that need release on abrupt teardown. + shell_held_inputs: HashMap, /// Temporary files staged from this client's local clipboard image pastes. pub(crate) staged_clipboard_files: Vec, /// Last coherent shell replacement sent to this client. @@ -91,62 +119,48 @@ impl ClientConnection { pub(crate) fn new( terminal_size: (u16, u16), cell_size: crate::kitty_graphics::HostCellSize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - outer_terminal_focus: Option, last_activity: u64, render_encoding: RenderEncoding, writer: Option, ) -> Self { Self::new_with_mode( - ClientConnectionMode::App, - None, + ClientConnectionMode::ClientShell, terminal_size, cell_size, - host_terminal_theme, - outer_terminal_focus, last_activity, render_encoding, - false, writer, ) } pub(crate) fn new_with_mode( mode: ClientConnectionMode, - keybindings: Option>, terminal_size: (u16, u16), cell_size: crate::kitty_graphics::HostCellSize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - outer_terminal_focus: Option, last_activity: u64, render_encoding: RenderEncoding, - pending_terminal_attach: bool, writer: Option, ) -> Self { Self { mode, - pending_terminal_attach, - keybindings, terminal_size, cell_size, - host_terminal_appearance: host_terminal_theme - .background - .map(crate::terminal_theme::RgbColor::inferred_appearance), - host_terminal_appearance_explicit: false, - host_terminal_theme, - outer_terminal_focus, - raw_input: crate::raw_input::RawInputFramer::default(), last_activity, render_state: ClientRenderState::new(render_encoding), - graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(), shell_graphics_delivery: crate::kitty_graphics::surface::DeliveryCache::default(), direct_graphics: false, pixel_mouse: false, - graphics_surface_reset_pending: false, + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + host_terminal_appearance: None, + host_terminal_appearance_explicit: false, + outer_terminal_focus: None, + host_keyboard_report_all_active: None, render_pending: false, + shell_mouse_capture: false, host_mouse_capture_active: None, host_sgr_pixels_active: None, - host_keyboard_report_all_active: None, + host_keyboard_protocol_active: None, + shell_held_inputs: HashMap::new(), staged_clipboard_files: Vec::new(), shell_snapshot: None, shell_projection_revision: 0, @@ -160,69 +174,151 @@ impl ClientConnection { self.render_state.request_repaint(); } - pub(crate) fn deferred_render(&self) -> DeferredRender { - if self.render_pending { - DeferredRender::Full - } else { - DeferredRender::None + pub(crate) fn track_shell_input( + &mut self, + target: ClientShellInputTarget, + events: &[ClientPaneInputEvent], + ) { + for event in events { + match event { + ClientPaneInputEvent::Key { + code, + modifiers, + kind: ClientKeyKind::Press, + shifted_codepoint, + tracks_release: true, + physical_key_id, + .. + } => { + self.shell_held_inputs.insert( + client_shell_key_press_id(code, *physical_key_id), + ClientShellHeldInput { + target: target.clone(), + release: ClientPaneInputEvent::Key { + code: code.clone(), + modifiers: *modifiers, + kind: ClientKeyKind::Release, + repeat_count: 1, + shifted_codepoint: *shifted_codepoint, + generated_text: None, + tracks_release: true, + physical_key_id: *physical_key_id, + }, + }, + ); + } + ClientPaneInputEvent::Key { + code, + kind: ClientKeyKind::Release, + physical_key_id, + .. + } => { + self.shell_held_inputs + .remove(&client_shell_key_press_id(code, *physical_key_id)); + } + ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Down(button), + position, + geometry, + modifiers, + .. + } + | ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Drag(button), + position, + geometry, + modifiers, + .. + } => { + let id = ClientShellPressId::Mouse(*button); + if matches!( + event, + ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Down(_), + .. + } + ) || self.shell_held_inputs.contains_key(&id) + { + self.shell_held_inputs.insert( + id, + ClientShellHeldInput { + target: target.clone(), + release: ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Up(*button), + position: *position, + geometry: *geometry, + modifiers: *modifiers, + lines: 1, + }, + }, + ); + } + } + ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Up(button), + .. + } => { + self.shell_held_inputs + .remove(&ClientShellPressId::Mouse(*button)); + } + ClientPaneInputEvent::Key { + kind: ClientKeyKind::Press | ClientKeyKind::Repeat, + .. + } + | ClientPaneInputEvent::TextCommit(_) + | ClientPaneInputEvent::Mouse { .. } + | ClientPaneInputEvent::Paste(_) => {} + } } } - pub(crate) fn clear_deferred_render(&mut self) { - self.render_pending = false; + pub(crate) fn drain_shell_held_inputs(&mut self) -> Vec { + self.shell_held_inputs + .drain() + .map(|(_, held)| held) + .collect() } - pub(crate) fn defer_full_render(&mut self) { - self.render_pending = true; - } - - pub(crate) fn take_deferred_render(&mut self) -> DeferredRender { - let deferred = self.deferred_render(); - self.clear_deferred_render(); - deferred - } - - pub(crate) fn is_full_app_client(&self) -> bool { - matches!(self.mode, ClientConnectionMode::App) && !self.pending_terminal_attach - } - - pub(crate) fn is_app_surface_client(&self) -> bool { - matches!( - self.mode, - ClientConnectionMode::App | ClientConnectionMode::ClientShell - ) && !self.pending_terminal_attach - } - - pub(crate) fn request_semantic_redraw_after_input(&mut self) { - self.render_state.reset_semantic_input_baseline(); - } - - pub(crate) fn update_host_theme_from_events( + pub(crate) fn update_host_theme( &mut self, - events: &[crate::raw_input::RawInputEvent], + update: &crate::protocol::ClientHostThemeUpdate, ) -> bool { let mut next_theme = self.host_terminal_theme; let mut changed = false; - for event in events { - match event { - crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { - next_theme = next_theme.with_color(*kind, *color); - if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) - && !self.host_terminal_appearance_explicit - { - changed |= - self.set_host_appearance(Some(color.inferred_appearance()), false); + + match update { + crate::protocol::ClientHostThemeUpdate::DefaultColor { kind, color } => { + let kind = match kind { + crate::protocol::ClientHostDefaultColorKind::Foreground => { + crate::terminal_theme::DefaultColorKind::Foreground } - } - crate::raw_input::RawInputEvent::HostPaletteColors { colors } => { - for &(index, color) in colors { - next_theme = next_theme.with_palette_color(index, color); + crate::protocol::ClientHostDefaultColorKind::Background => { + crate::terminal_theme::DefaultColorKind::Background } + }; + let color = (*color).into(); + next_theme = next_theme.with_color(kind, color); + if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) + && !self.host_terminal_appearance_explicit + { + changed |= self.set_host_appearance(Some(color.inferred_appearance()), false); } - crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { - changed |= self.set_host_appearance(Some(*appearance), true); + } + crate::protocol::ClientHostThemeUpdate::PaletteColors(colors) => { + for &(index, color) in colors { + next_theme = next_theme.with_palette_color(index, color.into()); } - _ => {} + } + crate::protocol::ClientHostThemeUpdate::Appearance(appearance) => { + let appearance = match appearance { + crate::protocol::ClientHostAppearance::Dark => { + crate::terminal_theme::HostAppearance::Dark + } + crate::protocol::ClientHostAppearance::Light => { + crate::terminal_theme::HostAppearance::Light + } + }; + changed |= self.set_host_appearance(Some(appearance), true); } } @@ -251,41 +347,37 @@ impl ClientConnection { true } - pub(crate) fn update_outer_focus_from_events( - &mut self, - events: &[crate::raw_input::RawInputEvent], - ) -> Option { - let next_focus = events - .iter() - .filter_map(|event| match event { - crate::raw_input::RawInputEvent::OuterFocusGained => Some(true), - crate::raw_input::RawInputEvent::OuterFocusLost => Some(false), - _ => None, - }) - .next_back()?; + pub(crate) fn deferred_render(&self) -> DeferredRender { + if self.render_pending { + DeferredRender::Full + } else { + DeferredRender::None + } + } - self.outer_terminal_focus = Some(next_focus); - Some(next_focus) + pub(crate) fn clear_deferred_render(&mut self) { + self.render_pending = false; + } + + pub(crate) fn defer_full_render(&mut self) { + self.render_pending = true; + } + + pub(crate) fn take_deferred_render(&mut self) -> DeferredRender { + let deferred = self.deferred_render(); + self.clear_deferred_render(); + deferred + } + + pub(crate) fn is_shell_client(&self) -> bool { + matches!(self.mode, ClientConnectionMode::ClientShell) } } -pub(crate) fn events_include_interaction(events: &[crate::raw_input::RawInputEvent]) -> bool { - events.iter().any(|event| { - matches!( - event, - crate::raw_input::RawInputEvent::Key(_) - | crate::raw_input::RawInputEvent::Text(_) - | crate::raw_input::RawInputEvent::Mouse(_) - | crate::raw_input::RawInputEvent::Paste(_) - | crate::raw_input::RawInputEvent::OuterFocusGained - ) - }) -} - -pub(crate) fn latest_app_client(clients: &HashMap) -> Option { +pub(crate) fn latest_shell_client(clients: &HashMap) -> Option { clients .iter() - .filter(|(_, client)| client.is_app_surface_client()) + .filter(|(_, client)| client.is_shell_client()) .max_by_key(|(_, client)| client.last_activity) .map(|(&client_id, _)| client_id) } @@ -316,7 +408,7 @@ pub(crate) fn render_targets( .iter() .filter(|(_, client)| { client.writer.is_some() - && (client.is_app_surface_client() + && (client.is_shell_client() || matches!( client.mode, ClientConnectionMode::TerminalAttach { .. } @@ -337,3 +429,73 @@ pub(crate) fn render_targets( targets.sort_by_key(|(client_id, _, _, is_foreground, _)| (*is_foreground, *client_id)); targets } + +#[cfg(test)] +mod tests { + use super::*; + + fn shell_client() -> ClientConnection { + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + crate::protocol::RenderEncoding::SemanticFrame, + None, + ) + } + + #[test] + fn semantic_text_press_does_not_create_a_server_release_lease() { + let mut client = shell_client(); + client.track_shell_input( + ClientShellInputTarget::Pane("w1:p1".into()), + &[ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind: ClientKeyKind::Press, + repeat_count: 1, + shifted_codepoint: None, + generated_text: Some("x".into()), + tracks_release: false, + physical_key_id: None, + }], + ); + + assert!(client.drain_shell_held_inputs().is_empty()); + } + + #[test] + fn physical_keys_with_the_same_semantic_code_keep_distinct_release_leases() { + let mut client = shell_client(); + let key = |kind, physical_key_id| ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + modifiers: 0, + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: Some(physical_key_id), + }; + client.track_shell_input( + ClientShellInputTarget::Pane("w1:p1".into()), + &[ + key(ClientKeyKind::Press, 13), + key(ClientKeyKind::Press, 108), + key(ClientKeyKind::Release, 13), + ], + ); + + let held = client.drain_shell_held_inputs(); + assert_eq!(held.len(), 1); + assert!(matches!( + &held[0].release, + ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + kind: ClientKeyKind::Release, + physical_key_id: Some(108), + .. + } + )); + } +} diff --git a/src/server/headless.rs b/src/server/headless.rs index 679f2b57..0fe5d659 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -21,9 +21,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -#[cfg(test)] -use crossterm::event::KeyModifiers; -use crossterm::event::MouseEventKind; use interprocess::local_socket::traits::Listener as _; #[cfg(windows)] use interprocess::local_socket::traits::Stream as _; @@ -31,7 +28,9 @@ use interprocess::local_socket::traits::Stream as _; use interprocess::local_socket::ListenerNonblockingMode; use ratatui::layout::Rect; use tokio::sync::mpsc; -use tracing::{debug, error, info, warn}; +#[cfg(windows)] +use tracing::error; +use tracing::{debug, info, warn}; use base64::Engine; use bytes::Bytes; @@ -57,8 +56,8 @@ use crate::server::client_shell::{ }; use crate::server::client_transport::ServerEvent; use crate::server::clients::{ - events_include_interaction, latest_app_client, render_targets, terminal_stream_client_ids, - ClientConnection, ClientConnectionMode, DeferredRender, + latest_shell_client, render_targets, terminal_stream_client_ids, ClientConnection, + ClientConnectionMode, ClientShellInputTarget, DeferredRender, }; use crate::server::keybindings::{app_keybindings, apply_keybindings}; use crate::server::notifications::{ @@ -66,17 +65,25 @@ use crate::server::notifications::{ }; use crate::server::pane_input::{ apply_client_pane_input_events, apply_client_popup_input_events, apply_terminal_attach_input, - apply_terminal_attach_scroll, + apply_terminal_attach_scroll, terminal_attach_mouse_position, }; use crate::server::socket_paths::{ client_socket_path, prepare_socket_path, restrict_socket_permissions, }; use crate::server::terminal_attach::paste_payload_for_runtime; +mod bootstrap; +mod lifecycle; +mod notifications; mod pane_graphics; +mod render; + +pub use bootstrap::run_server; +use lifecycle::wait_for_live_handoff_response_write; +#[cfg(unix)] +use lifecycle::wait_for_old_public_sockets_to_close; use crate::protocol::MAX_GRAPHICS_FRAME_SIZE; -use pane_graphics::RetainedGraphicsOutcome; #[cfg(test)] use crate::protocol::RenderEncoding; @@ -85,26 +92,6 @@ use crate::server::client_transport::ClientWriter; #[cfg(test)] use std::fs; -const LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT: Duration = Duration::from_secs(6); - -fn wait_for_live_handoff_response_write( - response_write_complete: Option>, -) { - let Some(response_write_complete) = response_write_complete else { - return; - }; - - match response_write_complete.recv_timeout(LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT) { - Ok(()) => {} - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - warn!("timed out waiting for live handoff response write; old server exiting"); - } - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - warn!("live handoff response writer disconnected; old server exiting"); - } - } -} - fn sound_notify_message(sound: crate::sound::Sound) -> &'static str { match sound { crate::sound::Sound::Done => "agent done", @@ -124,16 +111,6 @@ fn notification_show_result( .unwrap_or_else(|_| "{}".to_string()) } -fn notification_show_response_shown(response: &str) -> bool { - let Ok(response) = serde_json::from_str::(response) else { - return false; - }; - matches!( - response.result, - api::schema::ResponseResult::NotificationShow { shown: true, .. } - ) -} - fn non_empty_body(value: &str) -> Option { (!value.is_empty()).then(|| value.to_owned()) } @@ -165,42 +142,6 @@ impl RenderImpact { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PtyRenderState { - Clean, - Hidden, - Visible, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct RetainedRenderInput { - needs_full_render: bool, - needs_graphics_render: bool, - pty: PtyRenderState, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum RetainedRenderPlan { - Full, - Graphics, - Pty, - HiddenPty, -} - -fn retained_render_plan(input: RetainedRenderInput) -> RetainedRenderPlan { - if input.needs_full_render { - RetainedRenderPlan::Full - } else if input.needs_graphics_render && input.pty != PtyRenderState::Visible { - RetainedRenderPlan::Graphics - } else { - match input.pty { - PtyRenderState::Visible => RetainedRenderPlan::Pty, - PtyRenderState::Hidden => RetainedRenderPlan::HiddenPty, - PtyRenderState::Clean => RetainedRenderPlan::Full, - } - } -} - fn record_render_impact(source: &'static str, impact: RenderImpact) { let event = match (source, impact) { ("api_requests", RenderImpact::Graphics) => "graphics_render_cause.api_requests", @@ -212,64 +153,6 @@ fn record_render_impact(source: &'static str, impact: RenderImpact) { crate::render_prof::event(event); } -fn rect_fits_frame(rect: Rect, frame: &FrameData) -> bool { - rect.x.saturating_add(rect.width) <= frame.width - && rect.y.saturating_add(rect.height) <= frame.height -} - -fn apply_terminal_dirty_patch( - frame: &mut FrameData, - area: Rect, - patch: crate::pane::TerminalDirtyPatch, -) -> bool { - if !rect_fits_frame(area, frame) { - return false; - } - let width = usize::from(frame.width); - for (local_y, row_cells) in patch.rows { - if local_y >= area.height || row_cells.len() != usize::from(area.width) { - return false; - } - let frame_y = area.y + local_y; - let start = usize::from(frame_y) * width + usize::from(area.x); - let end = start + usize::from(area.width); - if end > frame.cells.len() { - return false; - } - frame.cells[start..end].clone_from_slice(&row_cells); - } - true -} - -fn dirty_patch_intersects_hyperlinks( - frame: &FrameData, - area: Rect, - patch: &crate::pane::TerminalDirtyPatch, -) -> bool { - if frame.hyperlinks.is_empty() || !rect_fits_frame(area, frame) { - return false; - } - let width = usize::from(frame.width); - for (local_y, _) in &patch.rows { - if *local_y >= area.height { - return true; - } - let frame_y = area.y + *local_y; - let start = usize::from(frame_y) * width + usize::from(area.x); - let end = start + usize::from(area.width); - if end > frame.cells.len() { - return true; - } - if frame.cells[start..end] - .iter() - .any(|cell| cell.hyperlink.is_some()) - { - return true; - } - } - false -} - // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -618,12 +501,6 @@ impl HeadlessServer { crate::render_prof::event("full_render_cause.scheduled_tasks"); } - if self.handle_deferred_requests_headless() { - needs_render = true; - needs_full_render = true; - needs_graphics_render = false; - } - self.poll_pending_alt_screen_reads(now); if self.process_deferred_alt_screen_reads() { needs_render = true; @@ -631,14 +508,14 @@ impl HeadlessServer { needs_graphics_render = false; } - if latest_app_client(&self.clients).is_some() && self.app.ensure_default_workspace() { + if latest_shell_client(&self.clients).is_some() && self.app.ensure_default_workspace() { needs_render = true; needs_full_render = true; needs_graphics_render = false; crate::render_prof::event("full_render_cause.default_workspace"); } - if self.app.pane_graphics.retain_live_panes(&self.app.state) { + if self.retain_live_pane_graphics() { needs_render = true; needs_graphics_render = true; } @@ -650,7 +527,7 @@ impl HeadlessServer { self.drain_client_config_reload_request(); self.sync_immediate_pty_sources(); self.stream_host_mouse_capture_mode(); - self.stream_host_keyboard_enhancement_flags(); + self.stream_direct_terminal_keyboard_mode(); // 7. Render virtually and stream frames. Hidden-only PTY work keeps a // bounded classification cadence without delaying presentation work @@ -694,53 +571,17 @@ impl HeadlessServer { needs_render = false; continue; } - if needs_full_render { - crate::render_prof::event("retained_gate.needs_full_render"); - } else if !pty_dirty { - crate::render_prof::event("retained_gate.not_pty_dirty"); - } - let pty = if !pty_dirty { - PtyRenderState::Clean - } else if self.pty_sources_visible_to_any_render_target(&render_request.pty_sources) - { - PtyRenderState::Visible + let hidden_only = pty_dirty + && !needs_full_render + && !needs_graphics_render + && !self.pty_sources_visible_to_any_render_target(&render_request.pty_sources); + if hidden_only { + crate::render_prof::event("render.skipped.hidden_sources"); } else { - PtyRenderState::Hidden - }; - let mut deferred_graphics = false; - let render_plan = retained_render_plan(RetainedRenderInput { - needs_full_render, - needs_graphics_render, - pty, - }); - let rendered_retained = match render_plan { - RetainedRenderPlan::Full => false, - RetainedRenderPlan::Graphics => { - match self.render_retained_graphics_update_and_stream() { - RetainedGraphicsOutcome::Sent => true, - RetainedGraphicsOutcome::Deferred => { - deferred_graphics = true; - false - } - RetainedGraphicsOutcome::Fallback => false, - } - } - RetainedRenderPlan::Pty => self.render_retained_pty_update_and_stream(), - RetainedRenderPlan::HiddenPty => { - crate::render_prof::event("render.skipped.hidden_sources"); - true - } - }; - if deferred_graphics { - needs_render = false; - continue; - } - if !rendered_retained { crate::render_prof::event("full_render.invoke"); self.render_and_stream(); } - self.app - .record_render_attempt(now, render_plan != RetainedRenderPlan::HiddenPty); + self.app.record_render_attempt(now, !hidden_only); needs_render = false; needs_full_render = false; needs_graphics_render = false; @@ -865,7 +706,7 @@ impl HeadlessServer { } // Save session on exit. - if !self.app.no_session { + if self.app.policy.persist_session { self.app.save_session_now(); } @@ -873,176 +714,6 @@ impl HeadlessServer { Ok(()) } - fn handle_deferred_requests_headless(&mut self) -> bool { - let mut needs_render = false; - - if self.app.state.request_complete_onboarding { - self.app.state.request_complete_onboarding = false; - self.app.open_settings_from_onboarding(); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_onboarding"); - } - - if self.app.state.request_new_workspace { - self.app.state.request_new_workspace = false; - let response = self.headless_workspace_create("headless.workspace.create", None, None); - if let Err(error) = response { - error!( - code = %error.code, - message = %error.message, - "failed to create workspace" - ); - } - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_new_workspace"); - } - - if self.app.state.request_new_tab { - self.app.state.request_new_tab = false; - let label = self.app.state.requested_new_tab_name.take(); - let response = self.headless_tab_create("headless.tab.create", label); - if let Err(error) = response { - error!( - code = %error.code, - message = %error.message, - "failed to create tab" - ); - } - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_new_tab"); - } - - if let Some(ws_idx) = self.app.state.request_new_linked_worktree.take() { - self.app.open_new_linked_worktree_dialog(ws_idx); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); - } - - if let Some(ws_idx) = self.app.state.request_open_existing_worktree.take() { - self.app.open_existing_worktree_dialog(ws_idx); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); - } - - if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() { - let response = self.headless_workspace_create( - "headless.workspace.create_cwd", - Some(cwd.display().to_string()), - None, - ); - if let Err(error) = response { - error!( - code = %error.code, - message = %error.message, - "failed to create workspace at requested cwd" - ); - self.app.state.mode = app::Mode::Navigate; - } - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_workspace_cwd"); - } - - if let Some(ws_idx) = self.app.state.request_remove_linked_worktree.take() { - self.app.open_remove_linked_worktree_confirmation(ws_idx); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); - } - - if self.app.state.request_submit_worktree_create { - self.app.state.request_submit_worktree_create = false; - self.app.submit_worktree_create_via_api(); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_worktree_submit"); - } - - if self.app.state.request_submit_worktree_open { - self.app.state.request_submit_worktree_open = false; - self.app.submit_worktree_open_via_api(); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_worktree_submit"); - } - - if self.app.state.request_submit_worktree_remove { - self.app.state.request_submit_worktree_remove = false; - self.app.submit_worktree_remove_via_api(); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_worktree_submit"); - } - - if self.app.state.request_reload_config { - self.app.state.request_reload_config = false; - self.reload_server_config(true); - needs_render = true; - crate::render_prof::event("full_render_cause.config_reload"); - } - - needs_render - } - - fn headless_workspace_create( - &mut self, - id: &'static str, - cwd: Option, - label: Option, - ) -> Result<(), api::schema::ErrorBody> { - self.dispatch_headless_runtime_mutation( - id, - api::schema::Method::WorkspaceCreate(api::schema::WorkspaceCreateParams { - source_workspace_id: None, - cwd, - focus: true, - label, - env: Default::default(), - }), - ) - } - - fn headless_tab_create( - &mut self, - id: &'static str, - label: Option, - ) -> Result<(), api::schema::ErrorBody> { - self.dispatch_headless_runtime_mutation( - id, - api::schema::Method::TabCreate(api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label, - env: Default::default(), - }), - ) - } - - fn dispatch_headless_runtime_mutation( - &mut self, - id: &'static str, - method: api::schema::Method, - ) -> Result<(), api::schema::ErrorBody> { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - self.handle_api_request_with_shutdown_check_inner( - api::ApiRequestMessage { - request: api::schema::Request { - id: id.to_string(), - method, - }, - respond_to, - response_write_complete: None, - stream_active: None, - }, - true, - ); - match response_rx.recv_timeout(Duration::from_secs(5)) { - Ok(response) => serde_json::from_str::(&response) - .map(|response| Err(response.error)) - .unwrap_or(Ok(())), - Err(err) => Err(api::schema::ErrorBody { - code: "internal_error".into(), - message: format!("headless runtime mutation response failed: {err}"), - }), - } - } - fn allocate_activity_stamp(&mut self) -> u64 { let stamp = self.next_activity_stamp; self.next_activity_stamp = self.next_activity_stamp.saturating_add(1); @@ -1126,11 +797,11 @@ impl HeadlessServer { } } - fn sync_headless_view_geometry(&mut self) { + fn sync_runtime_view_geometry(&mut self) { crate::ui::compute_view_without_resizing_panes( &mut self.app.state, &self.app.terminal_runtimes, - Rect::new(0, 0, self.headless_size.0, self.headless_size.1), + Rect::new(0, 0, self.effective_size.0, self.effective_size.1), ); } @@ -1148,7 +819,7 @@ impl HeadlessServer { self.effective_size = self.headless_size; self.app.state.outer_terminal_focus = None; self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default(); - self.sync_headless_view_geometry(); + self.sync_runtime_view_geometry(); let server_keybindings = self.server_keybindings.clone(); apply_keybindings(&mut self.app, &server_keybindings); self.sync_visible_server_config_diagnostic(false); @@ -1159,7 +830,7 @@ impl HeadlessServer { self.effective_size = self.headless_size; self.app.state.outer_terminal_focus = None; self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default(); - self.sync_headless_view_geometry(); + self.sync_runtime_view_geometry(); let server_keybindings = self.server_keybindings.clone(); apply_keybindings(&mut self.app, &server_keybindings); self.sync_visible_server_config_diagnostic(false); @@ -1167,7 +838,6 @@ impl HeadlessServer { }; let terminal_size = client.terminal_size; - let outer_terminal_focus = client.outer_terminal_focus; let host_cell_size = if self.app.state.kitty_graphics_enabled && client.cell_size.is_known() { client.cell_size @@ -1177,18 +847,15 @@ impl HeadlessServer { let host_terminal_theme = client.host_terminal_theme; let host_terminal_appearance = client.host_terminal_appearance; let host_terminal_appearance_explicit = client.host_terminal_appearance_explicit; - let uses_local_keybindings = client.keybindings.is_some(); - let keybindings = client - .keybindings - .as_deref() - .unwrap_or(&self.server_keybindings) - .clone(); + let outer_terminal_focus = client.outer_terminal_focus; self.effective_size = terminal_size; + self.sync_runtime_view_geometry(); self.app.state.outer_terminal_focus = outer_terminal_focus; self.app.state.host_cell_size = host_cell_size; - apply_keybindings(&mut self.app, &keybindings); - self.sync_visible_server_config_diagnostic(uses_local_keybindings); + let server_keybindings = self.server_keybindings.clone(); + apply_keybindings(&mut self.app, &server_keybindings); + self.sync_visible_server_config_diagnostic(false); if outer_terminal_focus == Some(true) { self.app.state.mark_active_tab_seen(); } @@ -1199,230 +866,6 @@ impl HeadlessServer { self.app.set_host_terminal_theme(host_terminal_theme); } - #[cfg(unix)] - fn perform_live_handoff( - &mut self, - params: crate::api::schema::ServerLiveHandoffParams, - ) -> io::Result<()> { - info!("starting live handoff"); - let import_exe = params.import_exe.as_deref().map(std::path::PathBuf::from); - let socket_path = crate::server::handoff::handoff_socket_path(); - let token = format!( - "{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - ); - let listener = match crate::server::handoff::bind_listener(&socket_path) { - Ok(listener) => listener, - Err(err) => { - self.handoff_in_progress = false; - return Err(err); - } - }; - - let mut pane_by_terminal = HashMap::new(); - for ws in &self.app.state.workspaces { - for tab in &ws.tabs { - for (pane_id, pane) in &tab.panes { - pane_by_terminal.insert(pane.attached_terminal_id.clone(), pane_id.raw()); - } - } - } - if pane_by_terminal.len() > crate::server::handoff::MAX_FDS_PER_HANDOFF { - let _ = std::fs::remove_file(&socket_path); - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "live handoff supports at most {} panes in one update; close panes or restart herdr normally", - crate::server::handoff::MAX_FDS_PER_HANDOFF - ), - )); - } - - self.handoff_in_progress = true; - self.disconnect_all_clients_for_handoff(); - let _ = reject_pending_client_connections(&self.client_listener); - - let mut paused_terminal_ids = Vec::new(); - for terminal_id in pane_by_terminal.keys() { - if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) { - if let Err(err) = runtime.pause_handoff_reader(Duration::from_secs(2)) { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - paused_terminal_ids.push(terminal_id.clone()); - } - } - - let snapshot = crate::persist::capture( - &self.app.state.workspaces, - &self.app.state.terminals, - &self.app.terminal_runtimes, - self.app.state.active, - self.app.state.selected, - self.app.state.sidebar_width, - self.app.state.sidebar_section_split, - self.app.state.collapsed_space_keys.clone(), - ); - - let mut handoff_entries = Vec::new(); - for (terminal_id, runtime) in self.app.terminal_runtimes.iter() { - let Some(pane_id) = pane_by_terminal.get(terminal_id).copied() else { - continue; - }; - let mut handoff_runtime = runtime.handoff_runtime_state(pane_id); - let has_agent_session = self - .app - .state - .terminals - .get(terminal_id) - .is_some_and(|terminal| terminal.persisted_agent_session.is_some()); - if !has_agent_session { - handoff_runtime.initial_history_ansi = runtime.handoff_history_ansi(); - } - handoff_entries.push((terminal_id.clone(), handoff_runtime)); - } - - let panes = handoff_entries - .iter() - .map(|(_, runtime)| runtime.clone()) - .collect(); - let manifest = crate::server::handoff::manifest_for( - snapshot, - panes, - params.expected_protocol, - params.expected_version, - self.api_window_title.clone(), - ); - let mut import_child = match crate::server::handoff::spawn_handoff_import( - import_exe.as_deref(), - &socket_path, - &token, - ) { - Ok(child) => child, - Err(err) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - }; - let child_pid = import_child.id(); - info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server"); - - let mut fds = Vec::new(); - let duplicate_result = (|| { - for (terminal_id, _) in &handoff_entries { - let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) else { - continue; - }; - fds.push(runtime.duplicate_handoff_fd()?); - } - Ok::<(), io::Error>(()) - })(); - if let Err(err) = duplicate_result { - for fd in fds { - let _ = unsafe { libc::close(fd) }; - } - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - - let mut stream = match crate::server::handoff::accept_and_validate_on( - listener, - &socket_path, - &token, - &manifest, - ) { - Ok(stream) => stream, - Err(err) => { - for fd in fds { - let _ = unsafe { libc::close(fd) }; - } - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - }; - - let send_result = crate::server::handoff::send_fds_and_wait_restored(&mut stream, &fds); - for fd in fds { - let _ = unsafe { libc::close(fd) }; - } - if let Err(err) = send_result { - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - - if let Some(api_server) = &self.api_server { - let _ = api_server.remove_socket_file_if_owned(); - } else { - let _ = std::fs::remove_file(crate::api::socket_path()); - } - let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity); - if let Err(err) = crate::server::handoff::wait_ready(&mut stream) { - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - match self.wait_then_restore_public_sockets_after_failed_handoff() { - Ok(()) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - } - Err(restore_err) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(io::Error::other(format!( - "handoff replacement server did not become ready: {err}; old server could not restore public sockets: {restore_err}" - ))); - } - } - return Err(io::Error::other(format!( - "handoff replacement server did not become ready: {err}" - ))); - } - if let Err(err) = crate::server::handoff::report_committed(&mut stream) { - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - match self.wait_then_restore_public_sockets_after_failed_handoff() { - Ok(()) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - } - Err(restore_err) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(io::Error::other(format!( - "handoff replacement server was ready, but commit failed: {err}; old server could not restore public sockets: {restore_err}" - ))); - } - } - return Err(err); - } - - for (terminal_id, runtime) in self.app.terminal_runtimes.drain_for_handoff() { - if !pane_by_terminal.contains_key(&terminal_id) { - continue; - } - debug!(terminal = %terminal_id, "preserving pane runtime for handoff"); - runtime.preserve_for_handoff(); - } - crate::server::handoff::wait_owned_ack(&mut stream); - - Ok(()) - } - - fn finish_live_handoff_shutdown(&mut self) { - self.shutting_down = true; - self.app.state.should_quit = true; - self.app.no_session = true; - info!("live handoff completed; old server exiting"); - } - - #[cfg(not(unix))] - fn perform_live_handoff( - &mut self, - _params: crate::api::schema::ServerLiveHandoffParams, - ) -> io::Result<()> { - Err(io::Error::other("live handoff is only supported on Unix")) - } - fn sync_visible_server_config_diagnostic(&mut self, uses_local_keybindings: bool) { let visible = if uses_local_keybindings { &self.server_config_diagnostic_without_keybindings @@ -1436,68 +879,6 @@ impl HeadlessServer { } } - #[cfg(unix)] - fn restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { - let api_tx = self - .api_tx - .clone() - .ok_or_else(|| io::Error::other("cannot restore api socket without api sender"))?; - let api_server = api::start_server_with_stop_control( - api_tx, - self.app.event_hub.clone(), - self.should_quit.clone(), - )?; - - let client_path = client_socket_path(); - prepare_socket_path(&client_path)?; - let listener = bind_local_listener(&client_path)?; - restrict_socket_permissions(&client_path)?; - let client_socket_identity = socket_file_identity(&client_path)?; - listener.set_nonblocking(ListenerNonblockingMode::Accept)?; - - self.api_server = Some(api_server); - self.client_listener = listener; - self.client_socket_path = client_path; - self.client_socket_identity = client_socket_identity; - Ok(()) - } - - #[cfg(unix)] - fn wait_then_restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { - let timeout = crate::server::handoff::COMMIT_TIMEOUT + Duration::from_secs(2); - wait_for_old_public_sockets_to_close(timeout)?; - self.restore_public_sockets_after_failed_handoff() - } - - #[cfg(unix)] - fn rollback_handoff_before_commit( - &mut self, - socket_path: &Path, - paused_terminal_ids: &[crate::terminal::TerminalId], - ) { - for terminal_id in paused_terminal_ids { - if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) { - runtime.set_handoff_reader_paused(false); - } - } - self.handoff_in_progress = false; - let _ = std::fs::remove_file(socket_path); - } - - #[cfg(unix)] - fn nudge_handoff_panes_on_first_client_attach(&mut self) { - if !self.pending_handoff_repaint_nudge { - return; - } - self.pending_handoff_repaint_nudge = false; - self.app - .terminal_runtimes - .nudge_child_redraw_after_handoff(); - } - - #[cfg(not(unix))] - fn nudge_handoff_panes_on_first_client_attach(&mut self) {} - fn reload_server_config(&mut self, notify_success: bool) -> crate::config::ConfigReloadReport { let server_keybindings = self.server_keybindings.clone(); apply_keybindings(&mut self.app, &server_keybindings); @@ -1540,7 +921,7 @@ impl HeadlessServer { } fn promote_latest_remaining_client(&mut self) -> bool { - let next_foreground = latest_app_client(&self.clients); + let next_foreground = latest_shell_client(&self.clients); let changed = next_foreground != self.foreground_client_id; self.foreground_client_id = next_foreground; self.sync_foreground_client_state(); @@ -1550,7 +931,7 @@ impl HeadlessServer { fn app_client_count(&self) -> usize { self.clients .values() - .filter(|client| client.is_app_surface_client() && client.writer.is_some()) + .filter(|client| client.is_shell_client() && client.writer.is_some()) .count() } @@ -1558,9 +939,10 @@ impl HeadlessServer { self.app_client_count() == 1 && self.foreground_client_id.is_some_and(|id| { self.clients.get(&id).is_some_and(|client| { - client.is_app_surface_client() + client.is_shell_client() && client.writer.is_some() && client.direct_graphics + && client.pixel_mouse }) }) } @@ -1572,10 +954,9 @@ impl HeadlessServer { fn remove_client(&mut self, client_id: u64) -> bool { self.retire_direct_graphics_for_client(client_id); let was_foreground = self.foreground_client_id == Some(client_id); - self.app.clear_input_source(client_id); - self.send_client_graphics_cleanup(client_id); let removed = self.clients.remove(&client_id); - if let Some(removed) = removed { + if let Some(mut removed) = removed { + self.release_client_shell_inputs(client_id, &mut removed); crate::server::clipboard_image::remove_files(removed.staged_clipboard_files); if let ClientConnectionMode::TerminalAttach { terminal_id } = removed.mode { self.terminal_attach_owners.remove(&terminal_id); @@ -1594,6 +975,36 @@ impl HeadlessServer { } } + fn release_client_shell_inputs(&mut self, client_id: u64, client: &mut ClientConnection) { + for held in client.drain_shell_held_inputs() { + let result = match held.target { + ClientShellInputTarget::Pane(pane_id) => { + let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id) + else { + continue; + }; + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { + continue; + }; + apply_client_pane_input_events(runtime, &[held.release]) + } + ClientShellInputTarget::Popup(terminal_id) => { + let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { + continue; + }; + apply_client_popup_input_events(runtime, &[held.release]) + } + }; + if let Err(err) = result { + warn!(client_id, err = %err, "client shell teardown release failed"); + } + } + } + fn client_removal_needs_shared_resize(&self, client_id: u64) -> bool { if self.foreground_client_id == Some(client_id) { return true; @@ -1615,77 +1026,6 @@ impl HeadlessServer { } } - fn send_client_graphics_cleanup(&mut self, client_id: u64) { - let (writer, bytes) = match self.clients.get_mut(&client_id) { - Some(client) => { - let bytes = client.graphics_cache.clear_bytes(); - (client.writer.as_ref().cloned(), bytes) - } - None => return, - }; - if bytes.is_empty() { - return; - } - let Some(writer) = writer else { - return; - }; - let Ok(serialized) = Self::frame_server_message(&ServerMessage::Graphics { bytes }) else { - return; - }; - writer.replace_with_cleanup(serialized); - } - - fn send_all_clients_graphics_cleanup(&mut self) { - let client_ids = self.clients.keys().copied().collect::>(); - for client_id in client_ids { - self.send_client_graphics_cleanup(client_id); - } - } - - fn update_client_host_theme_from_events( - &mut self, - client_id: u64, - events: &[crate::raw_input::RawInputEvent], - ) -> bool { - let Some(client) = self.clients.get_mut(&client_id) else { - return false; - }; - - if !client.update_host_theme_from_events(events) { - return false; - } - - if self.foreground_client_id == Some(client_id) { - let mut changed = self.app.set_host_terminal_appearance_state( - client.host_terminal_appearance, - client.host_terminal_appearance_explicit, - ); - changed |= self.app.set_host_terminal_theme(client.host_terminal_theme); - if changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } - changed - } else { - false - } - } - - fn update_client_outer_focus_from_events( - &mut self, - client_id: u64, - events: &[crate::raw_input::RawInputEvent], - ) { - let Some(client) = self.clients.get_mut(&client_id) else { - return; - }; - let Some(next_focus) = client.update_outer_focus_from_events(events) else { - return; - }; - if self.foreground_client_id == Some(client_id) { - self.app.state.outer_terminal_focus = Some(next_focus); - } - } - /// Accepts pending client connections from the non-blocking listener. #[cfg(unix)] fn accept_client_connections(&mut self) -> io::Result<()> { @@ -1775,47 +1115,142 @@ impl HeadlessServer { .map(|resolved| resolved.terminal_id) } - fn write_client_clipboard_image( - &mut self, + fn client_clipboard_image_target_is_valid( + &self, + client_id: u64, + target: &protocol::ClientClipboardImageTarget, + ) -> bool { + match target { + protocol::ClientClipboardImageTarget::DirectTerminal => { + self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::TerminalAttach { .. }) + }) + } + protocol::ClientClipboardImageTarget::Pane(pane_id) => { + !self.handoff_in_progress + && self.app.state.popup_pane.is_none() + && self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + && self.app.parse_pane_id(pane_id).is_some() + } + protocol::ClientClipboardImageTarget::Popup(terminal_id) => { + !self.handoff_in_progress + && self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + && self + .app + .state + .popup_pane + .as_ref() + .is_some_and(|popup| popup.terminal_id.as_str() == terminal_id) + } + } + } + + fn stage_client_clipboard_image( + &self, client_id: u64, extension: &str, data: &[u8], - ) -> std::io::Result { + ) -> std::io::Result { let staged = crate::server::clipboard_image::stage(client_id, extension, data)?; - if let Some(client) = self.clients.get_mut(&client_id) { - client.staged_clipboard_files.push(staged.path); - } info!(client_id, bytes = data.len(), path = %staged.paste_text, "staged client clipboard image"); - Ok(staged.paste_text) + Ok(staged) } - fn paste_client_clipboard_image_path(&mut self, client_id: u64, path: String) -> bool { - if let Some(ClientConnection { - mode: ClientConnectionMode::TerminalAttach { terminal_id }, - .. - }) = self.clients.get(&client_id) - { - if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { - let payload = paste_payload_for_runtime(runtime, &path); - if let Err(err) = runtime.try_send_bytes(Bytes::from(payload)) { - warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach clipboard image paste failed"); + fn paste_client_clipboard_image_path( + &mut self, + client_id: u64, + target: protocol::ClientClipboardImageTarget, + path: String, + ) -> bool { + match target { + protocol::ClientClipboardImageTarget::DirectTerminal => { + let Some(ClientConnection { + mode: ClientConnectionMode::TerminalAttach { terminal_id }, + .. + }) = self.clients.get(&client_id) + else { + return false; + }; + if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { + let payload = paste_payload_for_runtime(runtime, &path); + if let Err(err) = runtime.try_send_bytes(Bytes::from(payload)) { + warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach clipboard image paste failed"); + } } + true + } + protocol::ClientClipboardImageTarget::Pane(pane_id) => { + if self.handoff_in_progress + || self.app.state.popup_pane.is_some() + || !self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + { + return false; + } + let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id) + else { + return false; + }; + let foreground_changed = self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { + return foreground_changed; + }; + if let Err(err) = apply_client_pane_input_events( + runtime, + &[protocol::ClientPaneInputEvent::Paste(path)], + ) { + warn!(client_id, pane_id, err = %err, "client shell clipboard image paste failed"); + } + true + } + protocol::ClientClipboardImageTarget::Popup(terminal_id) => { + if self.handoff_in_progress + || !self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + { + return false; + } + let Some(popup_terminal_id) = self + .app + .state + .popup_pane + .as_ref() + .map(|popup| popup.terminal_id.clone()) + else { + return false; + }; + if popup_terminal_id.as_str() != terminal_id { + return false; + } + let foreground_changed = self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else { + return foreground_changed; + }; + if let Err(err) = apply_client_popup_input_events( + runtime, + &[protocol::ClientPaneInputEvent::Paste(path)], + ) { + warn!(client_id, terminal_id, err = %err, "client shell popup clipboard image paste failed"); + } + true } - return true; } - - let foreground_changed = self.promote_client_to_foreground(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } - if let Some(client) = self.clients.get_mut(&client_id) { - client.request_semantic_redraw_after_input(); - } - self.app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste(path)], - self.foreground_client_id == Some(client_id), - ); - true } fn resolve_terminal_session_target( @@ -1869,7 +1304,6 @@ impl HeadlessServer { client.mode = ClientConnectionMode::TerminalObserve { terminal_id: terminal_id.clone(), }; - client.pending_terminal_attach = false; client.render_state.reset_baseline(); client.last_activity = stamp; let was_foreground = self.foreground_client_id == Some(client_id); @@ -1919,383 +1353,54 @@ impl HeadlessServer { true } - fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState { - self.app - .state - .workspaces - .iter() - .find_map(|ws| { - ws.tabs.iter().find_map(|tab| { - let pane = tab.panes.get(&pane_id)?; - self.app - .state - .terminals - .get(&pane.attached_terminal_id) - .map(|terminal| terminal.state) - }) - }) - .unwrap_or(crate::detect::AgentState::Unknown) - } - - fn pane_effective_agent_label(&self, pane_id: crate::layout::PaneId) -> Option { - self.app.state.workspaces.iter().find_map(|ws| { - ws.tabs.iter().find_map(|tab| { - let pane = tab.panes.get(&pane_id)?; - self.app - .state - .terminals - .get(&pane.attached_terminal_id) - .and_then(|terminal| terminal.effective_agent_label()) - .map(str::to_string) - }) - }) - } - - fn forward_semantic_agent_notification( + fn handle_terminal_attach_mouse( &mut self, - update: &crate::app::actions::PaneStateUpdate, + client_id: u64, + kind: protocol::ClientMouseKind, + position: protocol::ClientMousePosition, + geometry: Option, + modifiers: u8, + lines: u16, ) -> bool { - if update.suppress_completion { + if self.handoff_in_progress { return false; } - self.forward_semantic_agent_transition( - update.ws_idx, - update.pane_id, - update.previous_state, - update.state, - update.previous_agent_label.as_deref(), - update.agent_label.as_deref(), - update.known_agent.or(update.previous_known_agent), - ) - } - - fn forward_semantic_agent_transition( - &mut self, - ws_idx: usize, - pane_id: crate::layout::PaneId, - previous_state: crate::detect::AgentState, - state: crate::detect::AgentState, - previous_agent_label: Option<&str>, - agent_label: Option<&str>, - known_agent: Option, - ) -> bool { - let Some(kind) = crate::app::actions::notification_toast_for_state_change_with_agent_labels( - false, - previous_state, - state, - previous_agent_label, - agent_label, + let Some(client) = self.clients.get(&client_id) else { + return false; + }; + let ClientConnectionMode::TerminalAttach { terminal_id } = &client.mode else { + return false; + }; + let terminal_id = terminal_id.clone(); + let terminal_size = client.terminal_size; + let cell_size = client.cell_size; + let pixel_mouse = client.pixel_mouse; + let host_sgr_pixels_active = client.host_sgr_pixels_active == Some(true); + let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { + return false; + }; + let Some(position) = terminal_attach_mouse_position( + runtime, + terminal_size, + cell_size, + pixel_mouse, + host_sgr_pixels_active, + position, + geometry, ) else { return false; }; - let Some(workspace) = self.app.state.workspaces.get(ws_idx) else { - return false; - }; - let Some(tab_idx) = workspace.find_tab_index_for_pane(pane_id) else { - return false; - }; - let Some(tab_number) = workspace.public_tab_number(tab_idx) else { - return false; - }; - let Some(public_pane_id) = self.app.public_pane_id(ws_idx, pane_id) else { - return false; - }; - let Some(agent_label) = agent_label.or(previous_agent_label) else { - return false; - }; - let (semantic_kind, event_text, sound) = match kind { - crate::app::state::ToastKind::NeedsAttention => ( - protocol::SemanticNotificationKind::NeedsAttention, - "needs attention", - Some(protocol::SemanticNotificationSound::Request), - ), - crate::app::state::ToastKind::Finished => ( - protocol::SemanticNotificationKind::Finished, - "finished", - Some(protocol::SemanticNotificationSound::Done), - ), - crate::app::state::ToastKind::UpdateInstalled => ( - protocol::SemanticNotificationKind::UpdateInstalled, - "updated", - None, - ), - }; - let workspace_id = workspace.id.clone(); - let tab_id = crate::workspace::public_tab_id_for_number(&workspace_id, tab_number); - let workspace_label = - workspace.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes); - let context = - crate::app::actions::notification_context(workspace, &workspace_label, ws_idx, pane_id); - let agent = known_agent - .map(crate::detect::agent_label) - .map(str::to_owned); - self.send_to_client_shells(ServerMessage::SemanticNotification( - protocol::SemanticNotification { - kind: semantic_kind, - title: format!("{agent_label} {event_text}"), - body: non_empty_body(&context), - sound, - agent, - workspace_id: Some(workspace_id), - tab_id: Some(tab_id), - pane_id: Some(public_pane_id), - position: None, - }, - )) - } - - fn forward_pane_state_update_notifications_to_clients( - &mut self, - update: &crate::app::actions::PaneStateUpdate, - ) { - if self.app.state.toast_config.delay_seconds != 0 { - return; - } - - let is_active_tab = self - .app - .state - .pane_is_in_active_tab(update.ws_idx, update.pane_id); - let suppress_active_tab_notifications = - self.active_tab_suppresses_notifications(is_active_tab); - - if !update.suppress_completion && self.app.state.sound.allows(update.known_agent) { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - update.previous_state, - update.state, - update.previous_agent_label.as_deref(), - update.agent_label.as_deref(), - ) - { - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - } - - if !should_forward_toast_to_clients(self.app.state.toast_config.delivery) { - return; - } - let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update( - suppress_active_tab_notifications, - update, - ) else { - return; - }; - let Some(ws) = self.app.state.workspaces.get(update.ws_idx) else { - return; - }; - let Some(agent_label) = update.agent_label.as_deref() else { - return; - }; - let event_text = match kind { - crate::app::state::ToastKind::NeedsAttention => "needs attention", - crate::app::state::ToastKind::Finished => "finished", - crate::app::state::ToastKind::UpdateInstalled => "updated", - }; - let workspace_label = - ws.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes); - let context = crate::app::actions::notification_context( - ws, - &workspace_label, - update.ws_idx, - update.pane_id, - ); - self.send_notify_to_foreground_client( - toast_notify_kind(self.app.state.toast_config.delivery) - .expect("toast forwarding requires a client notification kind"), - format!("{agent_label} {event_text}"), - non_empty_body(&context), - ); - } - - fn forward_agent_notification_delivery( - &mut self, - delivery: &crate::app::state::AgentNotificationDelivery, - ) { - if let Some(sound) = delivery.sound { - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - - if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { - if let Some(toast) = &delivery.client_notification { - self.send_notify_to_foreground_client( - toast_notify_kind(self.app.state.toast_config.delivery) - .expect("toast forwarding requires a client notification kind"), - &toast.title, - non_empty_body(&toast.context), - ); - } - } - } - - fn send_notify_to_foreground_client( - &mut self, - kind: protocol::NotifyKind, - message: impl Into, - body: Option, - ) -> bool { - self.send_to_foreground_client(ServerMessage::Notify { + let event = protocol::ClientPaneInputEvent::Mouse { kind, - message: message.into(), - body, - }) - } - - fn send_flat_toast_to_foreground_client( - &mut self, - kind: protocol::NotifyKind, - message: impl AsRef, - ) -> bool { - let (title, body) = crate::terminal_notify::split_message(message.as_ref()); - self.send_notify_to_foreground_client(kind, title, body.map(str::to_string)) - } - - fn handle_notification_show_api( - &mut self, - id: String, - params: api::schema::NotificationShowParams, - ) -> String { - use api::schema::NotificationShowReason; - - let Some(title) = sanitize_notification_text(¶ms.title, 80) else { - return serde_json::to_string(&api::schema::ErrorResponse { - id, - error: api::schema::ErrorBody { - code: "invalid_params".into(), - message: "notification title is empty".into(), - }, - }) - .unwrap_or_else(|_| "{}".to_string()); + position, + geometry: None, + modifiers, + lines: lines.max(1), }; - - let body = params - .body - .as_deref() - .and_then(|body| sanitize_notification_text(body, 240)); - let has_client_shell = self - .clients - .values() - .any(|client| matches!(client.mode, ClientConnectionMode::ClientShell)); - if has_client_shell { - if self.app.api_notification_rate_limited(Instant::now()) { - return notification_show_result(id, false, NotificationShowReason::RateLimited); - } - let sound = match params.sound { - api::schema::NotificationShowSound::None => None, - api::schema::NotificationShowSound::Done => { - Some(protocol::SemanticNotificationSound::Done) - } - api::schema::NotificationShowSound::Request => { - Some(protocol::SemanticNotificationSound::Request) - } - }; - let semantic_shown = self.send_to_client_shells(ServerMessage::SemanticNotification( - protocol::SemanticNotification { - kind: protocol::SemanticNotificationKind::Custom, - title: title.clone(), - body: body.clone(), - sound, - agent: None, - workspace_id: None, - tab_id: None, - pane_id: None, - position: params.position, - }, - )); - let foreground_is_app = self - .foreground_client_id - .and_then(|client_id| self.clients.get(&client_id)) - .is_some_and(|client| matches!(client.mode, ClientConnectionMode::App)); - let legacy_shown = if foreground_is_app { - match self.app.state.toast_config.delivery { - config::ToastDelivery::Off => false, - config::ToastDelivery::Herdr => { - let response = self.app.handle_api_request_after_internal_events_drained( - api::schema::Request { - id: id.clone(), - method: api::schema::Method::NotificationShow(params.clone()), - }, - ); - let shown = notification_show_response_shown(&response); - if shown { - self.forward_api_notification_sound(params.sound); - } - shown - } - config::ToastDelivery::Terminal | config::ToastDelivery::System => { - let kind = toast_notify_kind(self.app.state.toast_config.delivery) - .expect("terminal/system delivery has notify kind"); - let shown = self.send_notify_to_foreground_client(kind, title, body); - if shown { - self.forward_api_notification_sound(params.sound); - } - shown - } - } - } else { - false - }; - let shown = semantic_shown || legacy_shown; - if shown { - self.app.mark_api_notification_shown(Instant::now()); - } - return notification_show_result( - id, - shown, - if shown { - NotificationShowReason::Shown - } else { - NotificationShowReason::NoForegroundClient - }, - ); + if let Err(err) = apply_client_pane_input_events(runtime, &[event]) { + warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach mouse input failed"); } - - match self.app.state.toast_config.delivery { - config::ToastDelivery::Off => { - return notification_show_result(id, false, NotificationShowReason::Disabled); - } - config::ToastDelivery::Herdr => { - let sound = params.sound; - let response = self.app.handle_api_request_after_internal_events_drained( - api::schema::Request { - id, - method: api::schema::Method::NotificationShow(params), - }, - ); - if notification_show_response_shown(&response) { - self.forward_api_notification_sound(sound); - } - return response; - } - config::ToastDelivery::Terminal | config::ToastDelivery::System => {} - } - - if self.app.api_notification_rate_limited(Instant::now()) { - return notification_show_result(id, false, NotificationShowReason::RateLimited); - } - let kind = toast_notify_kind(self.app.state.toast_config.delivery) - .expect("terminal/system delivery has notify kind"); - let shown = self.send_notify_to_foreground_client(kind, title, body); - if shown { - self.app.mark_api_notification_shown(Instant::now()); - self.forward_api_notification_sound(params.sound); - } - let reason = if shown { - NotificationShowReason::Shown - } else { - NotificationShowReason::NoForegroundClient - }; - - notification_show_result(id, shown, reason) + true } /// Pulls only titles reported dirty by the PTY parser. A focused pane title @@ -2417,385 +1522,6 @@ impl HeadlessServer { .unwrap_or_else(|_| "{}".to_string()) } - fn forward_api_notification_sound(&mut self, sound: api::schema::NotificationShowSound) { - let Some(sound) = sound.to_sound() else { - return; - }; - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - - /// Handles a single internal event with forwarding logic for clipboard, - /// sound, and toast notifications to connected clients. - /// - /// ALL internal events MUST be routed through this method to ensure - /// clipboard/notify forwarding is never bypassed. Do not call - /// `self.app.handle_internal_event()` directly for any internal event - /// in the headless server — use this method instead. - /// - /// Returns true if the event changed visual state (requiring a re-render). - fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool { - match &ev { - AppEvent::TerminalBell { pane_id, count } => { - if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) { - debug!( - pane = pane_id.raw(), - count, "dropped terminal bell without a foreground client" - ); - } - false - } - AppEvent::ClipboardWrite { content } => { - // Clipboard writes are client-local side effects. Forward them only to - // the foreground client instead of broadcasting to every attached client. - let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice()); - if self.send_to_foreground_client(ServerMessage::Clipboard { data }) { - self.app.show_clipboard_feedback(); - } - true - } - AppEvent::PrefixInputSource { active } => { - // Input-source switching is a client-local host side effect; forward it to the - // foreground client (which owns the real TIS switch + run-loop pump), like clipboard. - self.send_to_foreground_client(ServerMessage::PrefixInputSource { - active: *active, - }); - true - } - AppEvent::StateChanged { pane_id, agent, .. } => { - // Capture toast before handling. - let toast_before = self.app.state.toast.clone(); - let pane_id_val = *pane_id; - let agent_val = *agent; - - // Find the previous effective state of this pane before the event - // is processed. Notifications must follow effective state changes, - // not raw fallback reports that may be masked by hook authority. - let prev_state = self.pane_effective_state(pane_id_val); - let prev_agent_label = self.pane_effective_agent_label(pane_id_val); - - // Handle the state change (updates pane state, sets toast on AppState). - // Headless mode disables local sound playback separately from the - // sound policy so reloads can keep server-side notification policy live. - self.sync_foreground_client_state(); - let pane_updates = self.app.handle_internal_event_with_pane_updates(ev); - let suppress_completion = pane_updates - .iter() - .any(|update| update.pane_id == pane_id_val && update.suppress_completion); - for update in pane_updates - .iter() - .filter(|update| update.pane_id == pane_id_val) - { - self.forward_semantic_agent_notification(update); - } - - // Forward sound notification to clients when server-side sound policy allows it. - let is_active_tab = self - .app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - .is_some_and(|ws| { - ws.find_tab_index_for_pane(pane_id_val) - .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) - }); - - let suppress_active_tab_notifications = - self.active_tab_suppresses_notifications(is_active_tab); - - let next_state = self.pane_effective_state(pane_id_val); - let next_agent_label = self.pane_effective_agent_label(pane_id_val); - - if !suppress_completion - && self.app.state.toast_config.delay_seconds == 0 - && self.app.state.sound.allows(agent_val) - { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - next_agent_label.as_deref(), - ) - { - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - } - - let toast_msg = if !suppress_completion - && self.app.state.toast_config.delay_seconds == 0 - && should_forward_toast_to_clients(self.app.state.toast_config.delivery) - { - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - self.app - .state - .toast - .as_ref() - .map(|toast| format!("{}: {}", toast.title, toast.context)) - } else { - toast_message_from_state_change( - &self.app.state, - &self.app.terminal_runtimes, - pane_id_val, - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - ) - } - } else { - None - }; - - if let Some(msg) = toast_msg { - self.send_flat_toast_to_foreground_client( - toast_notify_kind(self.app.state.toast_config.delivery) - .expect("toast forwarding requires a client notification kind"), - msg, - ); - } - - true - } - AppEvent::HookStateReported { - pane_id, - agent_label, - .. - } => { - // Hook reports can be stale or no-op after sequence rejection. - // Forward only effective state changes observed after handling. - let toast_before = self.app.state.toast.clone(); - let pane_id_val = *pane_id; - let agent_val = crate::detect::parse_agent_label(agent_label); - - // Capture the previous effective state for this pane. Hook reports - // are already folded into pane.state; raw hook transitions must not - // produce a second notification path. - let prev_state = self.pane_effective_state(pane_id_val); - let prev_agent_label = self.pane_effective_agent_label(pane_id_val); - - self.sync_foreground_client_state(); - let pane_updates = self.app.handle_internal_event_with_pane_updates(ev); - let suppress_completion = pane_updates - .iter() - .any(|update| update.pane_id == pane_id_val && update.suppress_completion); - for update in pane_updates - .iter() - .filter(|update| update.pane_id == pane_id_val) - { - self.forward_semantic_agent_notification(update); - } - - // Forward sound notification based on the effective transition when - // server-side sound policy allows it. - let is_active_tab = self - .app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - .is_some_and(|ws| { - ws.find_tab_index_for_pane(pane_id_val) - .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) - }); - - let suppress_active_tab_notifications = - self.active_tab_suppresses_notifications(is_active_tab); - - let next_state = self.pane_effective_state(pane_id_val); - let next_agent_label = self.pane_effective_agent_label(pane_id_val); - - if !suppress_completion - && self.app.state.toast_config.delay_seconds == 0 - && self.app.state.sound.allows(agent_val) - { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - next_agent_label.as_deref(), - ) - { - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - } - - let toast_msg = if !suppress_completion - && self.app.state.toast_config.delay_seconds == 0 - && should_forward_toast_to_clients(self.app.state.toast_config.delivery) - { - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - self.app - .state - .toast - .as_ref() - .map(|toast| format!("{}: {}", toast.title, toast.context)) - } else { - toast_message_from_state_change( - &self.app.state, - &self.app.terminal_runtimes, - pane_id_val, - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - ) - } - } else { - None - }; - - if let Some(msg) = toast_msg { - self.send_flat_toast_to_foreground_client( - toast_notify_kind(self.app.state.toast_config.delivery) - .expect("toast forwarding requires a client notification kind"), - msg, - ); - } - - true - } - AppEvent::UpdateReady { - version, - install_command, - } => { - let toast_before = self.app.state.toast.clone(); - let version = version.clone(); - let install_command = install_command.clone(); - - self.app.handle_internal_event(ev); - self.send_to_client_shells(ServerMessage::SemanticNotification( - protocol::SemanticNotification { - kind: protocol::SemanticNotificationKind::UpdateInstalled, - title: format!("Herdr v{version} available"), - body: Some(crate::update::update_install_instruction(&install_command)), - sound: None, - agent: None, - workspace_id: None, - tab_id: None, - pane_id: None, - position: None, - }, - )); - - let toast_msg = - if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - self.app - .state - .toast - .as_ref() - .map(|toast| format!("{}: {}", toast.title, toast.context)) - } else { - Some(format!( - "v{version} available: {}", - crate::update::update_install_instruction(&install_command) - )) - } - } else { - None - }; - - if let Some(msg) = toast_msg { - self.send_flat_toast_to_foreground_client( - toast_notify_kind(self.app.state.toast_config.delivery) - .expect("toast forwarding requires a client notification kind"), - msg, - ); - } - - true - } - AppEvent::PaneDied { pane_id } => { - let pane_id_val = *pane_id; - let terminal_id = self.app.state.workspaces.iter().find_map(|ws| { - ws.tabs.iter().find_map(|tab| { - tab.panes - .get(pane_id) - .map(|pane| pane.attached_terminal_id.to_string()) - }) - }); - if let Some(update) = self - .app - .state - .publish_pane_process_exit_if_agent(pane_id_val) - { - self.app.emit_pane_state_update(&update); - self.forward_semantic_agent_notification(&update); - self.forward_pane_state_update_notifications_to_clients(&update); - } - - self.app.handle_internal_event(ev); - - if self.app.find_pane(pane_id_val).is_none() { - if let Some(terminal_id) = terminal_id { - self.shutdown_terminal_stream_clients( - &terminal_id, - format!("terminal {terminal_id} exited"), - ); - } - } - - true - } - _ => self.app.handle_internal_event_with_render_impact(ev), - } - } - - /// Drains internal events, forwarding clipboard, sound, and toast - /// notifications to connected clients instead of processing them locally. - /// - /// The server has no host terminal or audio subsystem, so we: - /// - Forward `ClipboardWrite` as `ServerMessage::Clipboard` to the - /// foreground client only. - /// - Detect when a sound would be played and forward as - /// `ServerMessage::Notify { kind: Sound }` to the foreground client. - /// - Detect when a toast is set on AppState and forward as - /// `ServerMessage::Notify` to the foreground client for terminal/system delivery. - fn drain_internal_events_with_forwarding(&mut self) -> bool { - self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT) - .1 - } - - fn drain_all_internal_events_with_forwarding(&mut self) -> bool { - let mut changed = false; - loop { - let (had_event, batch_changed) = - self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT); - changed |= batch_changed; - if !had_event || self.should_quit.load(Ordering::Acquire) { - break; - } - } - changed - } - - fn drain_internal_events_with_forwarding_up_to(&mut self, limit: usize) -> (bool, bool) { - let mut had_event = false; - let mut changed = false; - for _ in 0..limit { - let Ok(ev) = self.app.event_rx.try_recv() else { - break; - }; - had_event = true; - changed |= self.handle_internal_event_with_forwarding(ev); - } - (had_event, changed) - } - fn drain_client_config_reload_request(&mut self) { if !self.app.state.request_client_config_reload { return; @@ -2957,7 +1683,6 @@ impl HeadlessServer { fn disconnect_all_clients_for_handoff(&mut self) { let client_ids = self.clients.keys().copied().collect::>(); for client_id in client_ids { - self.send_client_graphics_cleanup(client_id); self.send_to_client( client_id, ServerMessage::ServerShutdown { @@ -3059,7 +1784,6 @@ impl HeadlessServer { client.mode = ClientConnectionMode::TerminalAttach { terminal_id: terminal_id.clone(), }; - client.pending_terminal_attach = false; client.render_state.reset_baseline(); client.last_activity = stamp; let was_foreground = self.foreground_client_id == Some(client_id); @@ -3083,92 +1807,12 @@ impl HeadlessServer { } fn client_is_pending_terminal_mode(&self, client_id: u64) -> bool { - self.clients.get(&client_id).is_some_and(|client| { - client.pending_terminal_attach && matches!(client.mode, ClientConnectionMode::App) - }) + self.clients + .get(&client_id) + .is_some_and(|client| matches!(client.mode, ClientConnectionMode::TerminalPending)) } /// Handles a server event. Returns true if the event requires a re-render. - fn handle_client_input_events( - &mut self, - client_id: u64, - events: Vec, - ) -> bool { - let source_was_foreground = self.foreground_client_id == Some(client_id); - let source_is_full_app = self - .clients - .get(&client_id) - .is_some_and(ClientConnection::is_full_app_client); - let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw( - &events, - self.app.state.redraw_on_focus_gained, - ); - let render_neutral_mouse_motion = - events_are_render_neutral_mouse_motion(&events, self.app.state.mode); - if let Some(client) = self.clients.get_mut(&client_id) { - if host_surface_redraw { - client.request_repaint(); - client.defer_full_render(); - } else if !render_neutral_mouse_motion { - // Ensure semantic clients receive one post-input frame even if the - // semantic buffer compares equal. Terminal-ANSI clients must keep their - // server-side blit baseline; resetting it here forces a full redraw on - // every keypress and makes remote sessions feel extremely slow. - client.request_semantic_redraw_after_input(); - } - } - if source_is_full_app { - self.update_client_outer_focus_from_events(client_id, &events); - if events - .iter() - .any(|event| matches!(event, crate::raw_input::RawInputEvent::OuterFocusLost)) - { - // Focus loss is not a teardown, so the pending URL click stays. - self.app.release_input_source_headless(client_id); - } - } - let events = events_for_app_routing(events, source_was_foreground, source_is_full_app); - let interaction = events_include_interaction(&events); - let foreground_changed = if interaction { - self.promote_client_to_foreground(client_id) - } else { - false - }; - if foreground_changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } - let theme_changed = self.update_client_host_theme_from_events(client_id, &events); - // Client-local theme reports were applied above; routing them again would update every - // pane once per palette entry instead of once per captured batch. - self.app.route_client_events_from(client_id, events, false); - if self.app.take_config_reloaded_from_disk() { - self.reload_server_config(false); - } else { - self.sync_foreground_client_state(); - } - - if self.app.state.detach_requested { - self.app.state.detach_requested = false; - info!(client_id, "client detach requested via keybind"); - - self.send_client_graphics_cleanup(client_id); - self.send_to_client( - client_id, - ServerMessage::ServerShutdown { - reason: Some("detached".to_owned()), - }, - ); - - if let Some(client) = self.clients.get_mut(&client_id) { - client.writer = None; - } - - false - } else { - foreground_changed || theme_changed || (interaction && !render_neutral_mouse_motion) - } - } - fn handle_server_event(&mut self, ev: ServerEvent) -> bool { if self.handoff_in_progress && Self::ignore_client_event_during_handoff(&ev) { return false; @@ -3181,11 +1825,8 @@ impl HeadlessServer { rows, cell_width_px, cell_height_px, - keybindings, + pixel_mouse, writer, - render_encoding, - direct_attach_requested, - direct_graphics, } => { if self.handoff_in_progress { if let Ok(message) = @@ -3200,45 +1841,27 @@ impl HeadlessServer { } return false; } - let first_app_client = !direct_attach_requested && self.app_client_count() == 0; info!( client_id, - cols, - rows, - cell_width_px, - cell_height_px, - ?render_encoding, - "client connected" + cols, rows, cell_width_px, cell_height_px, "direct terminal client connected" ); let last_activity = self.allocate_activity_stamp(); + let observed = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; + let pixel_mouse = pixel_mouse && observed.is_known(); let mut connection = ClientConnection::new_with_mode( - ClientConnectionMode::App, - keybindings, + ClientConnectionMode::TerminalPending, (cols, rows), - crate::kitty_graphics::HostCellSize { - width_px: cell_width_px, - height_px: cell_height_px, - }, - crate::terminal_theme::TerminalTheme::default(), - None, + observed, last_activity, - render_encoding, - direct_attach_requested, + protocol::RenderEncoding::TerminalAnsi, Some(writer), ); - connection.direct_graphics = direct_graphics; - connection.pixel_mouse = direct_graphics; + connection.pixel_mouse = pixel_mouse; self.clients.insert(client_id, connection); - if !direct_attach_requested { - self.foreground_client_id = Some(client_id); - } - if first_app_client { - self.app.mark_git_status_refresh_due(Instant::now()); - } - self.sync_foreground_client_state(); - self.resize_shared_runtime_to_effective_size(); - self.nudge_handoff_panes_on_first_client_attach(); - true + false } ServerEvent::ClientShellConnected { client_id, @@ -3249,6 +1872,7 @@ impl HeadlessServer { pixel_mouse, direct_graphics, endpoint_keybindings, + mouse_capture, writer, } => { if self.handoff_in_progress { @@ -3273,33 +1897,25 @@ impl HeadlessServer { render_encoding = ?protocol::RenderEncoding::SemanticFrame, "client connected" ); - if self.app.state.mode == app::Mode::Onboarding - && self.app.state.workspaces.is_empty() - { - self.app.state.mode = app::Mode::Navigate; - self.app.ensure_default_workspace(); - self.app.state.mode = app::Mode::Onboarding; - } + self.app.ensure_default_workspace(); let first_app_client = self.app_client_count() == 0; let last_activity = self.allocate_activity_stamp(); + let observed = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; let mut connection = ClientConnection::new_with_mode( ClientConnectionMode::ClientShell, - None, (surface_cols, surface_rows), - crate::kitty_graphics::HostCellSize { - width_px: cell_width_px, - height_px: cell_height_px, - }, - crate::terminal_theme::TerminalTheme::default(), - None, + observed, last_activity, protocol::RenderEncoding::SemanticFrame, - false, Some(writer), ); - connection.pixel_mouse = pixel_mouse; + connection.pixel_mouse = pixel_mouse && observed.is_known(); connection.direct_graphics = direct_graphics; connection.shell_uses_endpoint_keybindings = endpoint_keybindings; + connection.shell_mouse_capture = mouse_capture; connection.shell_projection_revision = 1; let config_diagnostic = if endpoint_keybindings { self.server_config_diagnostic.as_deref() @@ -3362,112 +1978,38 @@ impl HeadlessServer { } => self.handle_terminal_attach_scroll( client_id, source, direction, lines, column, row, modifiers, ), - ServerEvent::ClientInputPixels { + ServerEvent::ClientAttachMouse { client_id, - data, + kind, + position, geometry, - } => { - let coordinates_valid = crate::input::mouse::parse_report(&data) - .and_then(|(x, y)| geometry.cell(x, y)) - .is_some(); - let valid = coordinates_valid - && self.clients.get(&client_id).is_some_and(|client| { - let cell = client.cell_size; - client.is_full_app_client() - && client.host_sgr_pixels_active == Some(true) - && client.terminal_size == (geometry.cols, geometry.rows) - && cell.is_known() - && cell.width_px == geometry.width_px / u32::from(geometry.cols) - && cell.height_px == geometry.height_px / u32::from(geometry.rows) - }); - if !valid || self.handoff_in_progress || !self.focused_pane_graphics_demand() { - return false; - } - let foreground_changed = self.promote_client_to_foreground(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } - self.app - .route_client_pixel_mouse(client_id, &data, geometry) - || foreground_changed - } + modifiers, + lines, + } => self.handle_terminal_attach_mouse( + client_id, kind, position, geometry, modifiers, lines, + ), ServerEvent::ClientInput { client_id, data } => { if self.handoff_in_progress { debug!( client_id, len = data.len(), - "ignored client input during handoff" + "ignored direct terminal input during handoff" ); return false; } - debug!(client_id, len = data.len(), "client input received"); - if matches!( - self.clients.get(&client_id).map(|client| &client.mode), - Some(ClientConnectionMode::ClientShell) - ) { - return false; - } - if let Some(ClientConnection { + let Some(ClientConnection { mode: ClientConnectionMode::TerminalAttach { terminal_id }, .. }) = self.clients.get(&client_id) - { - if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { - if let Err(err) = apply_terminal_attach_input(runtime, data) { - warn!(client_id, terminal_id = %terminal_id, err = %err); - } - } - return true; - } - if matches!( - self.clients.get(&client_id).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalObserve { .. }) - ) { + else { return false; - } - let events = if let Some(client) = self.clients.get_mut(&client_id) { - let mut events = client.raw_input.push(&data); - // The thin client only forwards a bare ESC after its local input timeout. - if data.as_slice() == b"\x1b" { - events.extend(client.raw_input.flush_timeout()); - } - events - } else { - Vec::new() }; - self.handle_client_input_events(client_id, events) - } - ServerEvent::ClientInputEvents { client_id, events } => { - if self.handoff_in_progress { - debug!( - client_id, - len = events.len(), - "ignored client input events during handoff" - ); - return false; + if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { + if let Err(err) = apply_terminal_attach_input(runtime, data) { + warn!(client_id, terminal_id = %terminal_id, err = %err); + } } - debug!( - client_id, - len = events.len(), - "client input events received" - ); - if matches!( - self.clients.get(&client_id).map(|client| &client.mode), - Some(ClientConnectionMode::ClientShell) - ) { - return false; - } - if matches!( - self.clients.get(&client_id).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalObserve { .. }) - ) { - return false; - } - let events = events - .iter() - .map(crate::protocol::ClientInputEvent::to_raw_input_event) - .collect(); - self.handle_client_input_events(client_id, events) + true } ServerEvent::ClientPasteRejected { client_id, @@ -3494,6 +2036,7 @@ impl HeadlessServer { } ServerEvent::ClientClipboardImage { client_id, + target, extension, data, } => { @@ -3503,17 +2046,28 @@ impl HeadlessServer { extension = %extension, "client clipboard image received" ); - if matches!( - self.clients.get(&client_id).map(|client| &client.mode), - Some( - ClientConnectionMode::TerminalObserve { .. } - | ClientConnectionMode::ClientShell - ) - ) { + if !self.client_clipboard_image_target_is_valid(client_id, &target) { return false; } - match self.write_client_clipboard_image(client_id, &extension, &data) { - Ok(path) => self.paste_client_clipboard_image_path(client_id, path), + match self.stage_client_clipboard_image(client_id, &extension, &data) { + Ok(staged) => { + let routed = self.paste_client_clipboard_image_path( + client_id, + target, + staged.paste_text, + ); + if routed { + if let Some(client) = self.clients.get_mut(&client_id) { + client.staged_clipboard_files.push(staged.path); + } else { + crate::server::clipboard_image::remove_files(vec![staged.path]); + return false; + } + } else { + crate::server::clipboard_image::remove_files(vec![staged.path]); + } + routed + } Err(err) => { warn!(client_id, err = %err, "failed to stage client clipboard image"); true @@ -3526,27 +2080,29 @@ impl HeadlessServer { rows, cell_width_px, cell_height_px, + pixel_mouse, } => { info!( client_id, - cols, rows, cell_width_px, cell_height_px, "client resize" + cols, rows, cell_width_px, cell_height_px, pixel_mouse, "client resize" ); + let observed = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; + let pixel_mouse = pixel_mouse && observed.is_known(); let direct_terminal_id = if let Some(ClientConnection { mode: ClientConnectionMode::TerminalAttach { terminal_id }, terminal_size, cell_size, + pixel_mouse: client_pixel_mouse, render_state, .. }) = self.clients.get_mut(&client_id) { *terminal_size = (cols, rows); - let observed = crate::kitty_graphics::HostCellSize { - width_px: cell_width_px, - height_px: cell_height_px, - }; - if observed.is_known() { - *cell_size = observed; - } + *cell_size = observed; + *client_pixel_mouse = pixel_mouse; render_state.request_repaint(); Some((terminal_id.clone(), *cell_size)) } else { @@ -3559,37 +2115,23 @@ impl HeadlessServer { return true; } if let Some(ClientConnection { - mode: ClientConnectionMode::TerminalObserve { .. }, + mode: + ClientConnectionMode::TerminalObserve { .. } + | ClientConnectionMode::TerminalPending, terminal_size, cell_size, + pixel_mouse: client_pixel_mouse, render_state, .. }) = self.clients.get_mut(&client_id) { *terminal_size = (cols, rows); - let observed = crate::kitty_graphics::HostCellSize { - width_px: cell_width_px, - height_px: cell_height_px, - }; - if observed.is_known() { - *cell_size = observed; - } + *cell_size = observed; + *client_pixel_mouse = pixel_mouse; render_state.request_repaint(); return true; } - if let Some(client) = self.clients.get_mut(&client_id) { - client.terminal_size = (cols, rows); - let observed = crate::kitty_graphics::HostCellSize { - width_px: cell_width_px, - height_px: cell_height_px, - }; - if observed.is_known() { - client.cell_size = observed; - } - } - self.promote_client_to_foreground(client_id); - self.resize_shared_runtime_to_effective_size(); - true + false } ServerEvent::ClientShellResize { client_id, @@ -3597,6 +2139,7 @@ impl HeadlessServer { surface_rows, cell_width_px, cell_height_px, + pixel_mouse, } => { let Some(client) = self.clients.get_mut(&client_id) else { return false; @@ -3612,11 +2155,73 @@ impl HeadlessServer { if observed.is_known() { client.cell_size = observed; } + client.pixel_mouse = pixel_mouse && observed.is_known(); client.request_repaint(); self.promote_client_to_foreground(client_id); self.resize_shared_runtime_to_effective_size(); true } + ServerEvent::ClientShellHostTheme { client_id, update } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) { + return false; + } + if !client.update_host_theme(&update) { + return false; + } + if self.foreground_client_id != Some(client_id) { + return false; + } + let mut changed = self.app.set_host_terminal_appearance_state( + client.host_terminal_appearance, + client.host_terminal_appearance_explicit, + ); + changed |= self.app.set_host_terminal_theme(client.host_terminal_theme); + if changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + changed + } + ServerEvent::ClientShellFocus { client_id, focused } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) + || client.outer_terminal_focus == Some(focused) + { + return false; + } + client.outer_terminal_focus = Some(focused); + if focused { + self.promote_client_to_foreground(client_id); + self.resize_shared_runtime_to_effective_size_before_input(); + self.app + .send_outer_focus_event(crate::ghostty::FocusEvent::Gained); + true + } else if self.foreground_client_id == Some(client_id) { + self.app.state.outer_terminal_focus = Some(false); + self.app + .send_outer_focus_event(crate::ghostty::FocusEvent::Lost); + true + } else { + false + } + } + ServerEvent::ClientShellMouseCapture { client_id, enabled } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) + || client.shell_mouse_capture == enabled + { + return false; + } + client.shell_mouse_capture = enabled; + client.host_mouse_capture_active = None; + true + } ServerEvent::ClientShellPaneInput { client_id, pane_id, @@ -3629,23 +2234,33 @@ impl HeadlessServer { { return false; } + let pixel_mouse = self.clients.get(&client_id).is_some_and(|client| { + client.pixel_mouse && client.host_sgr_pixels_active == Some(true) + }); + let mut events = events; let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id) else { return false; }; - if self - .app - .state - .runtime_for_pane_in_workspace( - &self.app.terminal_runtimes, - workspace_index, - runtime_pane_id, - ) - .is_none() - { + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { return false; - } - if self.app.state.popup_pane.is_some() { + }; + super::pane_input::downgrade_ineligible_pixel_mouse( + &mut events, + pixel_mouse, + runtime.current_size(), + runtime.pixel_size(), + ); + if self.app.state.popup_pane.is_some() + || !self + .app + .state + .pane_visible_on_active_surface(workspace_index, runtime_pane_id) + { let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( &self.app.terminal_runtimes, workspace_index, @@ -3660,12 +2275,24 @@ impl HeadlessServer { if releases.is_empty() { return false; } + if let Some(client) = self.clients.get_mut(&client_id) { + client.track_shell_input( + ClientShellInputTarget::Pane(pane_id.clone()), + &releases, + ); + } if let Err(err) = apply_client_pane_input_events(runtime, &releases) { warn!(client_id, pane_id, err = %err, "targeted client shell release failed"); } return true; } - let foreground_changed = self.promote_client_to_foreground(client_id); + let interaction = client_pane_input_has_interaction(&events); + if let Some(client) = self.clients.get_mut(&client_id) { + client + .track_shell_input(ClientShellInputTarget::Pane(pane_id.clone()), &events); + } + let foreground_changed = + interaction && self.promote_client_to_foreground(client_id); if foreground_changed { self.resize_shared_runtime_to_effective_size_before_input(); } @@ -3693,6 +2320,10 @@ impl HeadlessServer { { return false; } + let pixel_mouse = self.clients.get(&client_id).is_some_and(|client| { + client.pixel_mouse && client.host_sgr_pixels_active == Some(true) + }); + let mut events = events; let Some(popup_terminal_id) = self .app .state @@ -3702,12 +2333,27 @@ impl HeadlessServer { else { return false; }; - if popup_terminal_id.as_str() != terminal_id - || self.app.terminal_runtimes.get(&popup_terminal_id).is_none() - { + if popup_terminal_id.as_str() != terminal_id { return false; } - let foreground_changed = self.promote_client_to_foreground(client_id); + let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else { + return false; + }; + super::pane_input::downgrade_ineligible_pixel_mouse( + &mut events, + pixel_mouse, + runtime.current_size(), + runtime.pixel_size(), + ); + let interaction = client_pane_input_has_interaction(&events); + if let Some(client) = self.clients.get_mut(&client_id) { + client.track_shell_input( + ClientShellInputTarget::Popup(terminal_id.clone()), + &events, + ); + } + let foreground_changed = + interaction && self.promote_client_to_foreground(client_id); if foreground_changed { self.resize_shared_runtime_to_effective_size_before_input(); } @@ -4494,883 +3140,16 @@ impl HeadlessServer { } } - if !skip_default_workspace && latest_app_client(&self.clients).is_some() { + if !skip_default_workspace && latest_shell_client(&self.clients).is_some() { changed |= self.app.ensure_default_workspace(); } changed } - fn focused_pane_graphics_demand(&self) -> bool { - self.app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - .and_then(crate::workspace::Workspace::focused_pane_id) - .is_some_and(|pane_id| self.app.pane_graphics.active_for_pane(pane_id)) - } - - fn stream_host_mouse_capture_mode(&mut self) { - let enabled = self - .app - .state - .should_capture_host_mouse_from(&self.app.terminal_runtimes); - let pixel_mouse_requested = self - .clients - .values() - .any(|client| client.is_app_surface_client() && client.pixel_mouse); - let sgr_pixels = pixel_mouse_requested - && self.focused_pane_graphics_demand() - && self - .app - .state - .active - .and_then(|ws_idx| { - self.app - .state - .workspaces - .get(ws_idx) - .and_then(crate::workspace::Workspace::focused_pane_id) - .and_then(|pane_id| { - self.app.state.runtime_for_pane_in_workspace( - &self.app.terminal_runtimes, - ws_idx, - pane_id, - ) - }) - }) - .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled); - let mut broken_clients: Vec = Vec::new(); - for (&client_id, client) in &mut self.clients { - if !client.is_app_surface_client() { - continue; - } - let client_sgr_pixels = sgr_pixels && client.pixel_mouse; - if client.host_mouse_capture_active == Some(enabled) - && client.host_sgr_pixels_active == Some(client_sgr_pixels) - { - continue; - } - let Some(writer) = &client.writer else { - continue; - }; - let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { - enabled, - sgr_pixels: client_sgr_pixels, - }) { - Ok(framed) => framed, - Err(err) => { - warn!(err = %err, "failed to serialize mouse capture mode for client"); - continue; - } - }; - if writer.control.send(serialized).is_err() { - debug!( - client_id, - "client writer channel closed during mouse capture update" - ); - broken_clients.push(client_id); - continue; - } - client.host_mouse_capture_active = Some(enabled); - client.host_sgr_pixels_active = Some(client_sgr_pixels); - } - - for client_id in broken_clients { - self.remove_client_and_resize_if_needed(client_id); - } - } - - fn stream_host_keyboard_enhancement_flags(&mut self) { - let report_all_keys = self.app.host_keyboard_report_all_requested(); - let serialized = match Self::frame_server_message(&ServerMessage::KittyKeyboardReportAll { - enabled: report_all_keys, - }) { - Ok(framed) => framed, - Err(err) => { - warn!(err = %err, "failed to serialize keyboard enhancement flags for clients"); - return; - } - }; - - let mut broken_clients = Vec::new(); - for (&client_id, client) in &mut self.clients { - if !client.is_full_app_client() - || client.host_keyboard_report_all_active == Some(report_all_keys) - { - continue; - } - let Some(writer) = &client.writer else { - continue; - }; - if writer.control.send(serialized.clone()).is_err() { - debug!( - client_id, - "client writer channel closed during keyboard enhancement update" - ); - broken_clients.push(client_id); - continue; - } - client.host_keyboard_report_all_active = Some(report_all_keys); - } - - for client_id in broken_clients { - self.remove_client_and_resize_if_needed(client_id); - } - } - - fn has_pending_presentation_work( - &self, - needs_full_render: bool, - needs_graphics_render: bool, - ) -> bool { - needs_full_render || needs_graphics_render || self.app.render_dirty.has_immediate_work() - } - - fn sync_immediate_pty_sources(&self) { - let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); - let mut pane_ids = if has_app_target { - self.app.state.app_surface_pane_ids() - } else { - HashSet::new() - }; - if !direct_terminal_targets.is_empty() { - for workspace in &self.app.state.workspaces { - for tab in &workspace.tabs { - pane_ids.extend(tab.panes.iter().filter_map(|(&pane_id, pane)| { - direct_terminal_targets - .contains(pane.attached_terminal_id.as_str()) - .then_some(pane_id) - })); - } - } - if let Some(popup) = &self.app.state.popup_pane { - if direct_terminal_targets.contains(popup.terminal_id.as_str()) { - pane_ids.insert(popup.pane_id); - } - } - } - self.app.render_dirty.set_immediate_pty_sources(pane_ids); - } - - fn pty_render_targets(&self) -> (bool, HashSet<&str>) { - let mut has_app_target = false; - let mut direct_terminal_targets = HashSet::new(); - for client in self - .clients - .values() - .filter(|client| client.writer.is_some()) - { - match &client.mode { - ClientConnectionMode::App | ClientConnectionMode::ClientShell - if client.is_app_surface_client() => - { - has_app_target = true; - } - ClientConnectionMode::TerminalAttach { terminal_id } - | ClientConnectionMode::TerminalObserve { terminal_id } => { - direct_terminal_targets.insert(terminal_id.as_str()); - } - ClientConnectionMode::App | ClientConnectionMode::ClientShell => {} - } - } - (has_app_target, direct_terminal_targets) - } - - fn pty_source_visible_to_render_targets( - &self, - pane_id: crate::layout::PaneId, - has_app_target: bool, - direct_terminal_targets: &HashSet<&str>, - ) -> bool { - let terminal_id = self.terminal_id_for_pane(pane_id); - (has_app_target && (terminal_id.is_none() || self.app_surface_contains_pane(pane_id))) - || terminal_id.is_none_or(|source| direct_terminal_targets.contains(source.as_str())) - } - - fn pty_sources_visible_to_any_render_target( - &self, - sources: &HashSet, - ) -> bool { - let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); - if !has_app_target && direct_terminal_targets.is_empty() { - return false; - } - - sources.iter().copied().any(|pane_id| { - self.pty_source_visible_to_render_targets( - pane_id, - has_app_target, - &direct_terminal_targets, - ) - }) - } - - fn terminal_id_for_pane( - &self, - pane_id: crate::layout::PaneId, - ) -> Option<&crate::terminal::TerminalId> { - if let Some(popup) = self - .app - .state - .popup_pane - .as_ref() - .filter(|popup| popup.pane_id == pane_id) - { - return Some(&popup.terminal_id); - } - self.app - .find_pane(pane_id) - .map(|(_, pane)| &pane.attached_terminal_id) - } - - fn app_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool { - if self - .app - .state - .popup_pane - .as_ref() - .is_some_and(|popup| popup.pane_id == pane_id) - { - return true; - } - let Some(workspace) = self - .app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - else { - return false; - }; - let Some(tab) = workspace.active_tab() else { - return false; - }; - if !tab.panes.contains_key(&pane_id) { - return false; - } - !tab.zoomed || tab.layout.focused() == pane_id - } - - fn render_retained_pty_update_and_stream(&mut self) -> bool { - crate::render_prof::event("retained.attempt"); - let retained_started = crate::render_prof::timer(); - macro_rules! retained_fallback { - ($reason:literal) => {{ - crate::render_prof::event(concat!("retained_fallback.", $reason)); - crate::render_prof::duration_since("retained.total", retained_started); - return false; - }}; - } - macro_rules! retained_success { - ($reason:literal) => {{ - crate::render_prof::event("retained.success"); - crate::render_prof::event(concat!("retained_success.", $reason)); - crate::render_prof::duration_since("retained.total", retained_started); - return true; - }}; - } - - if !self.retained_pty_update_allowed_by_app_state() { - retained_fallback!("unsafe_app_state"); - } - - let render_targets = render_targets(&self.clients, self.foreground_client_id); - let [(client_id, (cols, rows), cell_size, _is_foreground, mode)] = - render_targets.as_slice() - else { - retained_fallback!("multiple_or_no_target"); - }; - if !matches!(mode, ClientConnectionMode::App) { - retained_fallback!("not_app_client"); - } - let Some(client) = self.clients.get(client_id) else { - retained_fallback!("client_missing"); - }; - if client.deferred_render() != DeferredRender::None { - retained_fallback!("render_pending"); - } - if self.app.state.kitty_graphics_enabled && !client.graphics_cache.is_empty() { - retained_fallback!("graphics_cache_active"); - } - if client.graphics_surface_reset_pending { - retained_fallback!("graphics_surface_reset"); - } - if self.app.state.kitty_graphics_enabled - && cell_size.is_known() - && crate::kitty_graphics::has_visible_pane_graphics( - &self.app.state, - &self.app.pane_graphics, - &self.app.terminal_runtimes, - self.app.state.view.tab_surface(), - *cell_size, - ) - { - retained_fallback!("visible_kitty_graphics"); - } - let Some(mut frame) = client.render_state.last_frame().cloned() else { - retained_fallback!("no_last_frame"); - }; - if frame.width != *cols || frame.height != *rows { - retained_fallback!("frame_size_mismatch"); - } - frame.graphics.clear(); - - let Some(ws_idx) = self.app.state.active else { - retained_fallback!("no_active_workspace"); - }; - let pane_infos = self.app.state.view.pane_infos.clone(); - if pane_infos.is_empty() { - retained_fallback!("no_pane_info"); - } - - let mut touched = false; - for info in pane_infos { - if !rect_fits_frame(info.inner_rect, &frame) { - retained_fallback!("pane_rect_outside_frame"); - } - let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( - &self.app.terminal_runtimes, - ws_idx, - info.id, - ) else { - retained_fallback!("missing_runtime"); - }; - match runtime.collect_dirty_patch(info.inner_rect.width, info.inner_rect.height) { - crate::pane::TerminalDirtyPatchOutcome::Clean => { - crate::render_prof::event("retained.pane_clean"); - } - crate::pane::TerminalDirtyPatchOutcome::Fallback => { - retained_fallback!("dirty_patch_fallback"); - } - crate::pane::TerminalDirtyPatchOutcome::Patch(patch) => { - crate::render_prof::event("retained.pane_patch"); - crate::render_prof::counter("retained.patch_rows", patch.rows.len() as u64); - if dirty_patch_intersects_hyperlinks(&frame, info.inner_rect, &patch) { - retained_fallback!("hyperlink_intersection"); - } - if !apply_terminal_dirty_patch(&mut frame, info.inner_rect, patch) { - retained_fallback!("patch_apply_failed"); - } - touched = true; - } - } - } - - let previous_cursor = frame.cursor.clone(); - frame.cursor = crate::server::render_stream::focused_terminal_cursor( - &self.app.state, - &self.app.terminal_runtimes, - ); - let cursor_changed = frame.cursor != previous_cursor; - - if !touched && !cursor_changed { - retained_success!("clean_no_cursor_change"); - } - - let mut broken_clients = Vec::new(); - let sent = self.send_retained_frame_to_client(*client_id, frame, &mut broken_clients); - for broken_client in broken_clients { - self.remove_client_and_resize_if_needed(broken_client); - } - if sent { - retained_success!("sent"); - } - retained_fallback!("send_failed"); - } - - fn retained_pty_update_allowed_by_app_state(&self) -> bool { - self.app.state.mode == app::Mode::Terminal - && self.app.state.popup_pane.is_none() - && self.app.state.selection.is_none() - && self.app.state.copy_mode.is_none() - && self.app.state.context_menu.is_none() - && self.app.state.toast.is_none() - && self.app.state.copy_feedback.is_none() - && !self.app.full_redraw_pending - } - - fn send_retained_frame_to_client( - &mut self, - client_id: u64, - frame: FrameData, - broken_clients: &mut Vec, - ) -> bool { - let Some(client) = self.clients.get_mut(&client_id) else { - crate::render_prof::event("retained_send_fallback.client_missing"); - return false; - }; - let Some(writer) = client.writer.as_ref().cloned() else { - crate::render_prof::event("retained_send_fallback.writer_missing"); - return false; - }; - let prepare_started = crate::render_prof::timer(); - let Some(prepared) = client.render_state.prepare_frame(frame) else { - client.clear_deferred_render(); - crate::render_prof::event("retained_send.skip_identical"); - crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started); - return true; - }; - crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started); - let serialize_started = crate::render_prof::timer(); - let serialized = match Self::frame_server_message(prepared.message()) { - Ok(framed) => { - crate::render_prof::duration_since("retained_send.serialize", serialize_started); - framed - } - Err(protocol::FramingError::Oversized { claimed, max }) => { - warn!( - client_id, - claimed, max, "skipping oversized retained frame for client" - ); - crate::render_prof::event("retained_send_fallback.serialize_oversized"); - crate::render_prof::duration_since("retained_send.serialize", serialize_started); - return false; - } - Err(err) => { - warn!(client_id, err = %err, "failed to serialize retained frame for client"); - broken_clients.push(client_id); - crate::render_prof::event("retained_send_fallback.serialize_error"); - crate::render_prof::duration_since("retained_send.serialize", serialize_started); - return false; - } - }; - crate::render_prof::counter("retained_send.bytes", serialized.len() as u64); - - let send_started = crate::render_prof::timer(); - match writer.render.try_send(serialized) { - Ok(()) => { - client.clear_deferred_render(); - client.render_state.commit_sent_frame(prepared); - crate::render_prof::event("retained_send.sent"); - crate::render_prof::duration_since("retained_send.try_send", send_started); - true - } - Err(std::sync::mpsc::TrySendError::Full(_)) => { - client.defer_full_render(); - crate::render_prof::event("retained_send_fallback.queue_full"); - crate::render_prof::duration_since("retained_send.try_send", send_started); - debug!( - client_id, - "render queue full, deferring latest retained frame" - ); - false - } - Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { - debug!(client_id, "client writer channel closed, marking as broken"); - broken_clients.push(client_id); - crate::render_prof::event("retained_send_fallback.writer_disconnected"); - crate::render_prof::duration_since("retained_send.try_send", send_started); - false - } - } - } - - fn render_and_stream(&mut self) { - let full_started = crate::render_prof::timer(); - let render_targets = render_targets(&self.clients, self.foreground_client_id); - - if render_targets.is_empty() { - let (cols, rows) = self.effective_size; - let area = Rect::new(0, 0, cols, rows); - let resize_panes = self.app.state.view.pane_infos.is_empty(); - let render_started = crate::render_prof::timer(); - let _ = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut self.app.state, - &self.app.terminal_runtimes, - area, - resize_panes, - crate::kitty_graphics::HostCellSize::default(), - ); - crate::render_prof::duration_since("full_render.render_virtual", render_started); - self.app.full_redraw_pending = false; - crate::render_prof::duration_since("full_render.total", full_started); - debug!( - cols, - rows, resize_panes, "rendered virtual frame with no attached clients" - ); - return; - } - - let shell_snapshot_template = render_targets - .iter() - .any(|(_, _, _, _, mode)| matches!(mode, ClientConnectionMode::ClientShell)) - .then(|| client_shell_snapshot(&self.app, &self.client_shell_boot_id, 0, None)); - let mut broken_clients: Vec = Vec::new(); - let mut deferred_frame = false; - for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets { - let area = Rect::new(0, 0, cols, rows); - let is_app_client = matches!(mode, ClientConnectionMode::App); - let mut shell_projection_revision = 0; - if matches!(mode, ClientConnectionMode::ClientShell) { - let Some(client) = self.clients.get_mut(&client_id) else { - continue; - }; - let Some(mut candidate) = shell_snapshot_template.clone() else { - continue; - }; - candidate.config_diagnostic = if client.shell_uses_endpoint_keybindings { - self.server_config_diagnostic.clone() - } else { - self.server_config_diagnostic_without_keybindings.clone() - }; - candidate.revision = client.shell_projection_revision; - if client.shell_snapshot.as_ref() != Some(&candidate) { - client.shell_projection_revision = - client.shell_projection_revision.saturating_add(1); - candidate.revision = client.shell_projection_revision; - let message = ServerMessage::ClientShellSnapshot(Box::new(candidate.clone())); - let framed = match Self::frame_server_message(&message) { - Ok(framed) => framed, - Err(err) => { - warn!(client_id, err = %err, "failed to frame client shell replacement"); - broken_clients.push(client_id); - continue; - } - }; - let Some(writer) = client.writer.as_ref() else { - broken_clients.push(client_id); - continue; - }; - if writer.control.send(framed).is_err() { - broken_clients.push(client_id); - continue; - } - client.shell_snapshot = Some(candidate); - } - shell_projection_revision = client.shell_projection_revision; - } - let shell_graphics_delivery = self - .clients - .get(&client_id) - .map(|client| client.shell_graphics_delivery.clone()) - .unwrap_or_default(); - let mut surface_parts = None; - let mut frame = match mode { - ClientConnectionMode::App => { - let render_started = crate::render_prof::timer(); - let render_cell_size = - if self.app.state.kitty_graphics_enabled && cell_size.is_known() { - cell_size - } else { - crate::kitty_graphics::HostCellSize::default() - }; - let preserved_scroll = (!is_foreground).then_some(( - self.app.state.workspace_scroll, - self.app.state.agent_panel_scroll, - self.app.state.tab_scroll, - self.app.state.mobile_switcher_scroll, - )); - let (buffer, cursor) = - crate::server::render_stream::render_virtual_with_runtime_registry( - &mut self.app.state, - &self.app.terminal_runtimes, - area, - is_foreground, - render_cell_size, - ); - if let Some((workspace, agent_panel, tab, mobile_switcher)) = preserved_scroll { - self.app.state.workspace_scroll = workspace; - self.app.state.agent_panel_scroll = agent_panel; - self.app.state.tab_scroll = tab; - self.app.state.mobile_switcher_scroll = mobile_switcher; - } - crate::render_prof::duration_since( - "full_render.render_virtual", - render_started, - ); - let hyperlinks_started = crate::render_prof::timer(); - let hyperlinks = crate::server::render_stream::visible_hyperlinks( - &self.app.state, - &self.app.terminal_runtimes, - ); - crate::render_prof::duration_since( - "full_render.visible_hyperlinks", - hyperlinks_started, - ); - let frame_started = crate::render_prof::timer(); - let frame = FrameData::from_ratatui_buffer_with_hyperlinks( - &buffer, - cursor, - &hyperlinks, - ); - crate::render_prof::duration_since("full_render.frame_build", frame_started); - frame - } - ClientConnectionMode::ClientShell => { - let render_started = crate::render_prof::timer(); - let render_cell_size = if cell_size.is_known() { - cell_size - } else { - crate::kitty_graphics::HostCellSize::default() - }; - let crate::server::client_shell::RenderedPaneSurface { - frame, - panes, - splits, - popup, - graphics, - graphics_delivery: next_graphics_delivery, - } = render_client_shell_pane_surface( - &mut self.app, - area, - is_foreground, - render_cell_size, - &shell_graphics_delivery, - client_id, - ); - crate::render_prof::duration_since( - "full_render.render_tab_surface_virtual", - render_started, - ); - surface_parts = Some((panes, splits, popup, graphics, next_graphics_delivery)); - frame - } - ClientConnectionMode::TerminalAttach { terminal_id } - | ClientConnectionMode::TerminalObserve { terminal_id } => { - let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { - self.send_to_client( - client_id, - ServerMessage::ServerShutdown { - reason: Some(format!( - "terminal attach ended: terminal {terminal_id} not found" - )), - }, - ); - broken_clients.push(client_id); - continue; - }; - let render_started = crate::render_prof::timer(); - let (buffer, cursor) = - crate::server::render_stream::render_terminal_virtual(runtime, area); - crate::render_prof::duration_since( - "full_render.render_terminal_virtual", - render_started, - ); - let hyperlinks_started = crate::render_prof::timer(); - let hyperlinks = runtime.visible_hyperlinks(area); - crate::render_prof::duration_since( - "full_render.visible_hyperlinks", - hyperlinks_started, - ); - let frame_started = crate::render_prof::timer(); - let frame = FrameData::from_ratatui_buffer_with_hyperlinks( - &buffer, - cursor, - &hyperlinks, - ); - crate::render_prof::duration_since("full_render.frame_build", frame_started); - frame - } - }; - - let Some(client) = self.clients.get_mut(&client_id) else { - continue; - }; - let mut next_graphics_cache = client.graphics_cache.clone(); - let mut reset_graphics = Vec::new(); - let mut encoded = if is_app_client - && self.app.state.kitty_graphics_enabled - && cell_size.is_known() - { - if client.graphics_surface_reset_pending { - if self.app.pane_graphics.slots.is_empty() { - reset_graphics = next_graphics_cache.clear_bytes(); - } else { - next_graphics_cache = crate::kitty_graphics::HostGraphicsCache::default(); - } - } - let graphics_started = crate::render_prof::timer(); - let encoded = crate::kitty_graphics::encode_local_pane_graphics( - &self.app.state, - &self.app.pane_graphics, - &self.app.terminal_runtimes, - self.app.state.view.tab_surface(), - cell_size, - Some(crate::kitty_graphics::HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - &mut next_graphics_cache, - ); - crate::render_prof::duration_since("full_render.graphics_encode", graphics_started); - encoded - } else if self.app.pane_graphics.slots.is_empty() { - crate::kitty_graphics::EncodedGraphics { - bytes: next_graphics_cache.clear_bytes(), - incomplete: false, - } - } else { - next_graphics_cache.clear_next() - }; - if !reset_graphics.is_empty() { - reset_graphics.extend(encoded.bytes); - encoded.bytes = reset_graphics; - } - frame.graphics = encoded.bytes; - - let Some(writer) = client.writer.as_ref().cloned() else { - crate::render_prof::event("full_render.writer_missing"); - continue; - }; - let mut commit_graphics_cache = true; - if frame.graphics.len() > MAX_GRAPHICS_FRAME_SIZE { - warn!( - client_id, - graphics_bytes = frame.graphics.len(), - max = MAX_GRAPHICS_FRAME_SIZE, - "dropping oversized graphics payload for client frame" - ); - frame.graphics.clear(); - commit_graphics_cache = false; - encoded.incomplete = false; - } - let has_graphics = !frame.graphics.is_empty() - || surface_parts - .as_ref() - .is_some_and(|(_, _, _, graphics, _)| { - !graphics.assets.is_empty() - || !graphics.placements.is_empty() - || !graphics.retained_assets.is_empty() - }); - let mut next_shell_graphics_delivery = None; - let prepared = if let Some((panes, splits, popup, graphics, delivery)) = surface_parts { - next_shell_graphics_delivery = Some(delivery); - client - .render_state - .prepare_pane_surface(protocol::PaneSurfaceFrame { - boot_id: self.client_shell_boot_id.clone(), - projection_revision: shell_projection_revision, - frame, - panes, - splits, - popup, - graphics, - }) - } else { - client.render_state.prepare_frame(frame) - }; - let Some(mut prepared) = prepared else { - if commit_graphics_cache { - client.graphics_cache = next_graphics_cache; - client.graphics_surface_reset_pending = false; - } - if encoded.incomplete { - client.defer_full_render(); - deferred_frame = true; - } else { - client.clear_deferred_render(); - } - crate::render_prof::event("full_render.skip_identical"); - continue; - }; - let max = if has_graphics { - MAX_GRAPHICS_FRAME_SIZE - } else { - crate::protocol::MAX_FRAME_SIZE - }; - let mut shell_assets_deferred = false; - let serialized = match Self::frame_server_message_with_max(prepared.message(), max) { - Ok(frame) => frame, - Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => { - warn!( - client_id, - claimed, max, "dropping graphics from oversized frame for client" - ); - let framed = if prepared.strip_pane_surface_assets() { - next_shell_graphics_delivery = None; - shell_assets_deferred = true; - Self::frame_server_message(prepared.message()) - } else { - let Some(mut text_only_frame) = prepared.into_frame() else { - crate::render_prof::event("full_render.serialize_error"); - continue; - }; - text_only_frame.graphics.clear(); - let Some(text_only_prepared) = - client.render_state.prepare_frame(text_only_frame) - else { - client.clear_deferred_render(); - crate::render_prof::event("full_render.skip_identical_text_only"); - continue; - }; - let result = Self::frame_server_message(text_only_prepared.message()); - prepared = text_only_prepared; - result - }; - let framed = match framed { - Ok(framed) => framed, - Err(err) => { - warn!(client_id, err = %err, "failed to serialize text-only frame for client"); - broken_clients.push(client_id); - crate::render_prof::event("full_render.serialize_error"); - continue; - } - }; - commit_graphics_cache = false; - encoded.incomplete = false; - framed - } - Err(protocol::FramingError::Oversized { claimed, max }) => { - warn!( - client_id, - claimed, max, "skipping oversized frame for client" - ); - crate::render_prof::event("full_render.serialize_oversized"); - continue; - } - Err(err) => { - warn!(client_id, err = %err, "failed to serialize frame"); - broken_clients.push(client_id); - crate::render_prof::event("full_render.serialize_error"); - continue; - } - }; - let shell_graphics_pending = next_shell_graphics_delivery - .as_ref() - .is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending); - match writer.render.try_send(serialized) { - Ok(()) => { - if commit_graphics_cache { - client.graphics_cache = next_graphics_cache; - client.graphics_surface_reset_pending = false; - } - if let Some(delivery) = next_shell_graphics_delivery { - client.shell_graphics_delivery = delivery; - } - client.render_state.commit_sent_frame(prepared); - if encoded.incomplete || shell_graphics_pending || shell_assets_deferred { - client.defer_full_render(); - deferred_frame = true; - } else { - client.clear_deferred_render(); - } - crate::render_prof::event("full_render.sent"); - } - Err(std::sync::mpsc::TrySendError::Full(_)) => { - client.defer_full_render(); - deferred_frame = true; - } - Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { - broken_clients.push(client_id); - } - } - } - - if !broken_clients.is_empty() { - for client_id in broken_clients { - self.remove_client_and_resize_if_needed(client_id); - } - } - - let (cols, rows) = self.effective_size; - if !deferred_frame { - self.app.full_redraw_pending = false; - } - crate::render_prof::duration_since("full_render.total", full_started); - debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)"); - } - /// Handle scheduled tasks for the headless server. /// - /// Similar to `App::handle_scheduled_tasks` but without resize polling - /// (the server doesn't have a terminal to resize). + /// Similar to the former App scheduler but without terminal resize polling. fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool { let mut changed = false; @@ -5416,27 +3195,6 @@ impl HeadlessServer { } } - if self - .app - .copy_feedback_deadline - .is_some_and(|deadline| now >= deadline) - { - self.app.copy_feedback_deadline = None; - self.app.state.copy_feedback = None; - changed = true; - } - - if self - .app - .selection_autoscroll_deadline - .is_some_and(|deadline| now >= deadline) - { - self.app.tick_selection_autoscroll(now); - changed = true; - } - - changed |= self.app.clear_due_selection_highlight(now); - if self.has_app_client() { self.app.start_git_status_refresh_if_due(now); } @@ -5486,86 +3244,8 @@ impl HeadlessServer { } changed } - - /// Initiates graceful shutdown. - fn initiate_shutdown(&mut self) { - if self.shutting_down { - return; - } - info!("server shutdown initiated"); - self.shutting_down = true; - - // Clear client-local host graphics, then send ServerShutdown to all connected clients. - self.send_all_clients_graphics_cleanup(); - let shutdown_msg = ServerMessage::ServerShutdown { - reason: Some("server is shutting down".to_owned()), - }; - self.send_to_all_clients(shutdown_msg); - - // Give client writer threads a moment to flush the shutdown message. - // A short sleep ensures the message is written to the socket before - // we close the connections. - std::thread::sleep(Duration::from_millis(50)); - - // Signal the main loop to exit. - self.should_quit.store(true, Ordering::Release); - self.app.state.should_quit = true; - } - - /// Completes the shutdown sequence: send ServerShutdown to clients, - /// close client connections, remove socket files, and clean up. - async fn complete_shutdown(&mut self) -> io::Result<()> { - info!("completing server shutdown"); - self.reject_late_client_connections().await; - - // Send ServerShutdown to all remaining clients. - if !self.clients.is_empty() { - self.send_all_clients_graphics_cleanup(); - let shutdown_msg = ServerMessage::ServerShutdown { - reason: Some("server is shutting down".to_owned()), - }; - self.send_to_all_clients(shutdown_msg); - - // Give writer threads a moment to flush before closing. - std::thread::sleep(Duration::from_millis(50)); - } - - // Reject only the requests already queued when shutdown reached cleanup. - self.reject_queued_api_requests_for_shutdown(); - - // Close all client connections. - let staged_files = self - .clients - .drain() - .flat_map(|(_, client)| client.staged_clipboard_files) - .collect::>(); - crate::server::clipboard_image::remove_files(staged_files); - - // Remove socket files. - self.cleanup_sockets()?; - - Ok(()) - } - - /// Removes socket files created by the server. - fn cleanup_sockets(&self) -> io::Result<()> { - if let Err(err) = - remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity) - { - if err.kind() != io::ErrorKind::NotFound { - warn!( - path = %self.client_socket_path.display(), - err = %err, - "failed to remove client socket on shutdown" - ); - } - } - Ok(()) - } } -// Pane applications render their own motion responses through PTY output. Only Herdr modes with -// hover selection mutate the current frame directly from a plain mouse-move event. fn client_pane_input_releases_press(event: &protocol::ClientPaneInputEvent) -> bool { matches!( event, @@ -5579,52 +3259,10 @@ fn client_pane_input_releases_press(event: &protocol::ClientPaneInputEvent) -> b ) } -fn events_are_render_neutral_mouse_motion( - events: &[crate::raw_input::RawInputEvent], - mode: crate::app::Mode, -) -> bool { - !events.is_empty() - && !mode.mouse_motion_changes_view() - && events.iter().all(|event| { - matches!( - event, - crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent { - kind: MouseEventKind::Moved, - .. - }) - ) - }) -} - -fn events_for_app_routing( - events: Vec, - mut source_is_foreground: bool, - source_is_full_app: bool, -) -> Vec { +fn client_pane_input_has_interaction(events: &[protocol::ClientPaneInputEvent]) -> bool { events - .into_iter() - .filter_map(|event| match event { - crate::raw_input::RawInputEvent::OuterFocusGained - | crate::raw_input::RawInputEvent::OuterFocusLost - if !source_is_full_app => - { - None - } - crate::raw_input::RawInputEvent::OuterFocusGained => { - source_is_foreground = true; - Some(event) - } - crate::raw_input::RawInputEvent::OuterFocusLost if !source_is_foreground => None, - crate::raw_input::RawInputEvent::Key(_) - | crate::raw_input::RawInputEvent::Text(_) - | crate::raw_input::RawInputEvent::Mouse(_) - | crate::raw_input::RawInputEvent::Paste(_) => { - source_is_foreground = true; - Some(event) - } - _ => Some(event), - }) - .collect() + .iter() + .any(|event| !client_pane_input_releases_press(event)) } impl Drop for HeadlessServer { @@ -5702,7738 +3340,9 @@ fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option // --------------------------------------------------------------------------- // Entry point -// --------------------------------------------------------------------------- - -/// Run the headless server. This is the entry point called from main.rs. -pub fn run_server() -> io::Result<()> { - init_logging(); - crate::platform::raise_server_nofile_limit(); - - let args: Vec = std::env::args().collect(); - if args.get(2).map(String::as_str) == Some("--handoff-import") { - let socket_path = args - .get(3) - .map(PathBuf::from) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff socket"))?; - let token = args - .get(4) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff token"))?; - return run_handoff_import_server(&socket_path, token); - } - - let loaded_config = config::Config::load(); - let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let event_hub = api::EventHub::default(); - let should_quit = Arc::new(AtomicBool::new(false)); - - // Start the JSON API socket server. - let _api_server = match api::start_server_with_stop_control( - api_tx.clone(), - event_hub.clone(), - should_quit.clone(), - ) { - Ok(server) => server, - Err(err) if err.kind() == io::ErrorKind::AddrInUse => { - eprintln!("error: herdr server is already running"); - eprintln!("api socket: {}", api::socket_path().display()); - std::process::exit(1); - } - Err(err) => return Err(err), - }; - - let no_session = false; // Server always does session persistence. - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(io::Error::other)?; - - let result = rt.block_on(async { - // Create the App (with AppState, event channels, etc.). - let mut app = app::App::new( - &loaded_config.config, - no_session, - config::config_diagnostic_summary(&loaded_config.diagnostics), - api_rx, - event_hub, - ); - seed_startup_workspace_if_empty(&mut app); - - // Create the headless server. - let mut server = match HeadlessServer::new( - app, - &loaded_config.diagnostics, - Some(api_tx.clone()), - Some(_api_server), - should_quit, - ) { - Ok(server) => server, - Err(err) if err.kind() == io::ErrorKind::AddrInUse => { - eprintln!("error: herdr server is already running"); - eprintln!("client socket: {}", client_socket_path().display()); - std::process::exit(1); - } - Err(err) => return Err(err), - }; - - info!( - api_socket = %api::socket_path().display(), - client_socket = %client_socket_path().display(), - "herdr server started" - ); - print_ready_message(&api::socket_path(), &client_socket_path()); - server.app.run_plugin_startup_hooks(); - - server.run().await - }); - - rt.shutdown_timeout(Duration::from_millis(100)); - crate::logging::shutdown("server"); - result -} - -fn seed_startup_workspace_if_empty(app: &mut app::App) { - let Some(cwd) = take_startup_cwd() else { - return; - }; - - if !app.state.workspaces.is_empty() { - info!( - cwd = %cwd.display(), - "restored session already has workspaces; ignoring startup cwd" - ); - return; - } - - match app.create_workspace_with_options(cwd.clone(), true) { - Ok(_) => { - info!(cwd = %cwd.display(), "created startup workspace"); - } - Err(err) => { - warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace"); - app.state.mode = app::Mode::Navigate; - } - } -} - -fn take_startup_cwd() -> Option { - let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?; - std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR); - (!cwd.is_empty()).then(|| PathBuf::from(cwd)) -} - -#[cfg(unix)] -fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> { - let loaded_config = config::Config::load(); - let mut received = crate::server::handoff::receive(socket_path, token)?; - crate::server::handoff::log_import_result(received.manifest.panes.len()); - - let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let event_hub = api::EventHub::default(); - let should_quit = Arc::new(AtomicBool::new(false)); - - let mut imports = HashMap::new(); - for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) { - let pane_id = pane.pane_id; - imports.insert( - pane_id, - crate::handoff_runtime::ImportedHandoffRuntime { - master_fd: fd, - state: pane, - }, - ); - } - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(io::Error::other)?; - - let result = rt.block_on(async { - let app = app::App::new_from_handoff( - &loaded_config.config, - config::config_diagnostic_summary(&loaded_config.diagnostics), - api_rx, - event_hub.clone(), - &received.manifest.snapshot, - &mut imports, - )?; - crate::server::handoff::report_restored(&mut received.stream)?; - if std::env::var("HERDR_TEST_HANDOFF_IMPORT_FAIL").as_deref() == Ok("after_restored") { - return Err(io::Error::other( - "test handoff import failure after restored", - )); - } - wait_for_old_public_sockets_to_close(Duration::from_secs(5))?; - - let api_server = api::start_server_with_stop_control( - api_tx.clone(), - event_hub.clone(), - should_quit.clone(), - )?; - let mut server = HeadlessServer::new( - app, - &loaded_config.diagnostics, - Some(api_tx.clone()), - Some(api_server), - should_quit, - )?; - // Carried across before any client attaches, so the first title sent is - // the override rather than the configured one it replaced. - server.api_window_title = received.manifest.api_window_title.take(); - crate::server::handoff::report_ready(&mut received.stream)?; - crate::server::handoff::wait_committed(&mut received.stream)?; - server.app.assume_handoff_ownership(); - server.app.unpause_handoff_readers(); - server.pending_handoff_repaint_nudge = true; - if let Err(err) = crate::server::handoff::report_owned(&mut received.stream) { - warn!(err = %err, "failed to report handoff ownership; continuing as owner"); - } - info!("handoff import server started"); - print_ready_message(&api::socket_path(), &client_socket_path()); - server.app.run_plugin_startup_hooks(); - server.run().await - }); - - rt.shutdown_timeout(Duration::from_millis(100)); - crate::logging::shutdown("server"); - result -} - -#[cfg(unix)] -fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> { - let deadline = Instant::now() + timeout; - let api_socket = api::socket_path(); - let client_socket = client_socket_path(); - while Instant::now() < deadline { - let api_open = api_socket.exists() && crate::ipc::connect_local_stream(&api_socket).is_ok(); - let client_open = - client_socket.exists() && crate::ipc::connect_local_stream(&client_socket).is_ok(); - if !api_open && !client_open { - return Ok(()); - } - std::thread::sleep(Duration::from_millis(50)); - } - Err(io::Error::new( - io::ErrorKind::TimedOut, - "old server sockets did not close before handoff import bind", - )) -} - -#[cfg(not(unix))] -fn run_handoff_import_server(_socket_path: &Path, _token: &str) -> io::Result<()> { - Err(io::Error::other("live handoff is only supported on Unix")) -} - -fn print_ready_message(api_socket: &Path, client_socket: &Path) { - eprintln!("herdr server running; you can use any herdr CLI command in another terminal."); - eprintln!("api socket: {}", api_socket.display()); - eprintln!("client socket: {}", client_socket.display()); - eprintln!( - "logs: {}", - crate::session::data_dir() - .join("herdr-server.log") - .display() - ); - eprintln!("did you mean to open the Herdr TUI? run `herdr`; you do not need `herdr server`."); -} - -/// Initialize logging for the server process. -fn init_logging() { - crate::logging::init_file_logging("herdr-server.log"); -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] -mod tests { - use super::*; - - use crate::app::AppState; - use crate::protocol::{CellData, CursorState}; - use unicode_width::UnicodeWidthStr; - - #[path = "pane_graphics.rs"] - mod pane_graphics_tests; - - #[test] - fn retained_render_plan_covers_each_render_path() { - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: true, - needs_graphics_render: true, - pty: PtyRenderState::Hidden, - }), - RetainedRenderPlan::Full - ); - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: false, - needs_graphics_render: true, - pty: PtyRenderState::Hidden, - }), - RetainedRenderPlan::Graphics - ); - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: false, - needs_graphics_render: false, - pty: PtyRenderState::Visible, - }), - RetainedRenderPlan::Pty - ); - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: false, - needs_graphics_render: false, - pty: PtyRenderState::Hidden, - }), - RetainedRenderPlan::HiddenPty - ); - } - - fn test_headless_server() -> HeadlessServer { - test_headless_server_with_event_hub(api::EventHub::default()) - } - - fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServer { - let config = crate::config::Config::default(); - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = crate::app::App::new(&config, true, None, api_rx, event_hub); - - let dir = std::env::temp_dir().join(format!( - "hh-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - let _ = fs::create_dir_all(&dir); - let socket_path = dir.join("client.sock"); - let _ = fs::remove_file(&socket_path); - let listener = bind_local_listener(&socket_path).expect("bind test listener"); - let client_socket_identity = - socket_file_identity(&socket_path).expect("test listener socket identity"); - #[cfg(unix)] - listener - .set_nonblocking(ListenerNonblockingMode::Accept) - .expect("set listener nonblocking"); - let (server_event_tx, server_event_rx) = mpsc::channel(64); - let should_quit = Arc::new(AtomicBool::new(false)); - #[cfg(windows)] - spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()); - let server_keybindings = app_keybindings(&app); - let headless_size = app.state.headless_size; - - HeadlessServer { - app, - #[cfg(unix)] - api_tx: None, - api_server: None, - #[cfg(unix)] - client_listener: listener, - client_socket_path: socket_path, - client_socket_identity, - clients: HashMap::new(), - #[cfg(unix)] - next_client_id: 1, - foreground_client_id: None, - client_shell_boot_id: "test-boot".into(), - sent_window_title: None, - api_window_title: None, - server_keybindings, - server_config_diagnostic: None, - server_config_diagnostic_without_keybindings: None, - terminal_attach_owners: HashMap::new(), - pending_alt_screen_reads: Vec::new(), - deferred_alt_screen_reads: Vec::new(), - next_activity_stamp: 1, - headless_size, - effective_size: headless_size, - shutting_down: false, - handoff_in_progress: false, - #[cfg(unix)] - pending_handoff_repaint_nudge: false, - should_quit, - server_event_rx, - server_event_tx, - } - } - - fn shutdown_test_runtimes(server: &mut HeadlessServer) { - for (_, runtime) in server.app.terminal_runtimes.drain() { - runtime.shutdown(); - } - } - - fn read_server_message(bytes: Vec) -> ServerMessage { - let mut cursor = std::io::Cursor::new(bytes); - protocol::read_message(&mut cursor, MAX_FRAME_SIZE).expect("decode server message") - } - - fn read_server_frame(bytes: Vec) -> FrameData { - match protocol::read_message(&mut std::io::Cursor::new(bytes), MAX_GRAPHICS_FRAME_SIZE) - .expect("decode server frame") - { - ServerMessage::Frame(frame) => frame, - other => panic!("expected frame, got {other:?}"), - } - } - - fn frame_text(frame: &FrameData) -> String { - frame - .cells - .chunks(usize::from(frame.width)) - .map(|row| { - row.iter() - .map(|cell| cell.symbol.as_str()) - .collect::() - }) - .collect::>() - .join("\n") - } - - fn read_server_shutdown_reason(bytes: Vec) -> Option { - match read_server_message(bytes) { - ServerMessage::ServerShutdown { reason } => reason, - other => panic!("expected shutdown, got {other:?}"), - } - } - - #[test] - fn default_headless_size_is_effective_without_clients() { - let server = test_headless_server(); - - assert_eq!( - server.headless_size, - ( - crate::config::DEFAULT_HEADLESS_COLS, - crate::config::DEFAULT_HEADLESS_ROWS - ) - ); - assert_eq!(server.effective_size, server.headless_size); - } - - #[tokio::test] - async fn headless_api_reads_latest_title_without_spinner_event_flooding() { - let event_hub = api::EventHub::default(); - let mut server = test_headless_server_with_event_hub(event_hub.clone()); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("one")]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - server.app.state.sidebar_agents.rows = vec![vec![ - crate::config::AgentSidebarToken::TerminalTitleStripped, - ]]; - let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; - let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(crate::detect::Agent::Claude); - let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); - runtime.test_process_pty_bytes(b"\x1b]0;\xe2\xa0\x8b task\x07"); - server - .app - .terminal_runtimes - .insert(terminal_id.clone(), runtime); - server.app.render_dirty.request_terminal_title(pane_id); - - let first = headless_pane_list(&mut server).pop().unwrap(); - assert_eq!(first.terminal_title.as_deref(), Some("⠋ task")); - assert_eq!(first.terminal_title_stripped.as_deref(), Some("task")); - assert_eq!(pane_updated_events(&event_hub), 1); - let (buffer, _) = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut server.app.state, - &server.app.terminal_runtimes, - Rect::new(0, 0, 100, 30), - true, - crate::kitty_graphics::HostCellSize::default(), - ); - let rendered = buffer - .content - .iter() - .map(|cell| cell.symbol()) - .collect::(); - assert!(rendered.contains("task"), "rendered frame: {rendered:?}"); - - server - .app - .terminal_runtimes - .get(&terminal_id) - .unwrap() - .test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\"); - server.app.render_dirty.request_terminal_title(pane_id); - let second = headless_pane_list(&mut server).pop().unwrap(); - assert_eq!(second.terminal_title.as_deref(), Some("⠙ task")); - assert_eq!(second.terminal_title_stripped.as_deref(), Some("task")); - assert_eq!(pane_updated_events(&event_hub), 1); - } - - fn headless_pane_list(server: &mut HeadlessServer) -> Vec { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "list-titles".into(), - method: api::schema::Method::PaneList(api::schema::PaneListParams::default()), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - let response: api::schema::SuccessResponse = - serde_json::from_str(&response_rx.recv().unwrap()).unwrap(); - let api::schema::ResponseResult::PaneList { panes } = response.result else { - panic!("expected pane list"); - }; - panes - } - - fn pane_updated_events(event_hub: &api::EventHub) -> usize { - event_hub - .events_after(0) - .iter() - .filter(|(_, event)| event.event == api::schema::EventKind::PaneUpdated) - .count() - } - - #[test] - fn server_stop_interrupts_server_event_backlog() { - let mut server = test_headless_server(); - for client_id in 1..=64 { - server - .server_event_tx - .try_send(ServerEvent::ClientDisconnected { client_id }) - .unwrap(); - } - - server.should_quit.store(true, Ordering::Release); - - assert!(!server.drain_server_events()); - assert!(server.server_event_rx.try_recv().is_ok()); - shutdown_test_runtimes(&mut server); - } - - #[test] - fn headless_api_request_drains_all_pending_internal_events_before_reading_state() { - let mut server = test_headless_server(); - for i in 0..=crate::app::APP_EVENT_DRAIN_LIMIT { - server - .app - .event_tx - .try_send(AppEvent::UpdateReady { - version: format!("4.0.{i}"), - install_command: "herdr install".into(), - }) - .unwrap(); - } - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - assert!( - server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "headless_stop_after_events".into(), - method: api::schema::Method::ServerStop(api::schema::EmptyParams::default()), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }) - ); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - - assert_eq!(response["result"]["type"], "ok"); - let expected_version = format!("4.0.{}", crate::app::APP_EVENT_DRAIN_LIMIT); - assert_eq!( - server.app.state.update_available.as_deref(), - Some(expected_version.as_str()) - ); - assert!(server.app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn headless_deferred_workspace_create_uses_runtime_events() { - let event_hub = api::EventHub::default(); - let mut server = test_headless_server_with_event_hub(event_hub.clone()); - - server.app.state.request_new_workspace = true; - - assert!(server.handle_deferred_requests_headless()); - assert!(!server.app.state.request_new_workspace); - assert_eq!( - event_hub - .events_after(0) - .into_iter() - .map(|(_, event)| event.event) - .collect::>(), - vec![ - api::schema::EventKind::WorkspaceCreated, - api::schema::EventKind::TabCreated, - api::schema::EventKind::PaneCreated, - api::schema::EventKind::LayoutUpdated, - ] - ); - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn headless_deferred_named_tab_create_uses_runtime_events() { - let event_hub = api::EventHub::default(); - let mut server = test_headless_server_with_event_hub(event_hub.clone()); - server - .app - .create_workspace_with_options(std::env::temp_dir(), true) - .unwrap(); - let after_setup = event_hub.current_sequence(); - - server.app.state.request_new_tab = true; - server.app.state.requested_new_tab_name = Some("ops".into()); - - assert!(server.handle_deferred_requests_headless()); - assert!(!server.app.state.request_new_tab); - assert_eq!(server.app.state.requested_new_tab_name, None); - let events = event_hub.events_after(after_setup); - assert_eq!( - events - .iter() - .map(|(_, event)| event.event) - .collect::>(), - vec![ - api::schema::EventKind::TabCreated, - api::schema::EventKind::PaneCreated, - api::schema::EventKind::LayoutUpdated, - ] - ); - let tab_created = events - .iter() - .find_map(|(_, event)| match &event.data { - api::schema::EventData::TabCreated { tab } => Some(tab), - _ => None, - }) - .expect("tab created event"); - assert_eq!(tab_created.label, "ops"); - shutdown_test_runtimes(&mut server); - } - - fn window_title_test_server() -> (HeadlessServer, std::sync::mpsc::Receiver>) { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - - let (client_tx, control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.promote_client_to_foreground(1); - drain_window_titles(&control_rx); - (server, control_rx) - } - - /// The test client writer drains its queue on a background thread, so - /// reading a pushed message needs a timeout rather than `try_recv`. - fn next_window_title( - control_rx: &std::sync::mpsc::Receiver>, - ) -> Option> { - let deadline = Instant::now() + Duration::from_secs(5); - while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { - let Ok(bytes) = control_rx.recv_timeout(remaining) else { - return None; - }; - if let ServerMessage::WindowTitle { title } = read_server_message(bytes) { - return Some(title); - } - } - None - } - - fn drain_window_titles(control_rx: &std::sync::mpsc::Receiver>) { - while control_rx.recv_timeout(Duration::from_millis(50)).is_ok() {} - } - - fn no_window_title(control_rx: &std::sync::mpsc::Receiver>) -> bool { - while let Ok(bytes) = control_rx.recv_timeout(Duration::from_millis(200)) { - if let ServerMessage::WindowTitle { .. } = read_server_message(bytes) { - return false; - } - } - true - } - - #[test] - fn window_title_waits_for_a_foreground_client_to_exist() { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; - server.app.state.active = Some(0); - server.app.configure_window_title("{workspace}"); - - // The server renders before the first client attaches. Nothing was - // delivered, so nothing may be recorded as delivered either. - server.sync_window_title(); - assert_eq!(server.sent_window_title, None); - - let (client_tx, control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.promote_client_to_foreground(1); - server.sync_window_title(); - - assert_eq!( - next_window_title(&control_rx), - Some(Some("herd".to_string())) - ); - shutdown_test_runtimes(&mut server); - } - - #[test] - fn an_attaching_client_gets_the_title_even_when_it_has_not_changed() { - let (mut server, first_control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}"); - server.sync_window_title(); - assert_eq!( - next_window_title(&first_control_rx), - Some(Some("herd".to_string())) - ); - - // ClientConnected assigns the foreground client directly rather than - // going through promote_client_to_foreground, so the cache must notice - // the new client on its own. - let (client_tx, second_control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(2); - server.sync_window_title(); - - assert_eq!( - next_window_title(&second_control_rx), - Some(Some("herd".to_string())) - ); - shutdown_test_runtimes(&mut server); - } - - #[test] - fn configured_window_title_reaches_the_foreground_client_once_per_change() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}/{tab}"); - - server.sync_window_title(); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herd/1".to_string())) - ); - - // An unchanged title must not re-emit an OSC on every render. - server.sync_window_title(); - assert!(no_window_title(&control_rx)); - - server.app.state.workspaces[0].tabs[0].custom_name = Some("build".into()); - server.sync_window_title(); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herd/build".to_string())) - ); - - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn focused_terminal_title_syncs_without_requesting_a_sidebar_render() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title("{terminal_title}"); - server.app.state.ensure_test_terminals(); - let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; - let terminal_id = server.app.state.workspaces[0] - .terminal_id(pane_id) - .expect("terminal") - .clone(); - let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); - runtime.test_process_pty_bytes("\x1b]0;⠋ building\x07".as_bytes()); - server - .app - .terminal_runtimes - .insert(terminal_id.clone(), runtime); - - assert_eq!( - server.sync_terminal_title_sources(&HashSet::from([pane_id])), - (false, true) - ); - assert_eq!( - next_window_title(&control_rx), - Some(Some("building".to_string())) - ); - - server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .test_process_pty_bytes("\x1b]0;⠙ building\x07".as_bytes()); - assert_eq!( - server.sync_terminal_title_sources(&HashSet::from([pane_id])), - (false, true) - ); - assert!(no_window_title(&control_rx)); - - shutdown_test_runtimes(&mut server); - } - - #[test] - fn a_foreground_client_without_a_writer_does_not_cache_the_window_title() { - let (mut server, _control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}"); - - // A detached client keeps its entry but loses its writer, so nothing - // reaches a terminal even though the targeted send reports success. - if let Some(client) = server.clients.get_mut(&1) { - client.writer = None; - } - server.sync_window_title(); - assert!(server.sent_window_title.is_none()); - - // Attaching again has to deliver the title rather than skip it as sent. - let (client_tx, control_rx, _render_rx) = test_client_writer(); - if let Some(client) = server.clients.get_mut(&1) { - client.writer = Some(client_tx); - } - server.sync_window_title(); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herd".to_string())) - ); - - shutdown_test_runtimes(&mut server); - } - - #[test] - fn empty_window_title_config_leaves_the_outer_title_alone() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title(""); - - server.sync_window_title(); - - assert!(no_window_title(&control_rx)); - shutdown_test_runtimes(&mut server); - } - - #[test] - fn api_window_title_wins_until_it_is_cleared() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}"); - - server.handle_client_window_title_api("set".into(), Some("herdr api".into())); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herdr api".to_string())) - ); - - server.app.state.workspaces[0].custom_name = Some("ops".into()); - server.sync_window_title(); - assert!(no_window_title(&control_rx)); - - // Clearing hands the title back to ui.window_title, not to "herdr". - server.handle_client_window_title_api("clear".into(), None); - assert_eq!( - next_window_title(&control_rx), - Some(Some("ops".to_string())) - ); - - shutdown_test_runtimes(&mut server); - } - - #[test] - fn clearing_the_api_title_falls_back_to_herdr_when_window_titles_are_disabled() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title(""); - - server.handle_client_window_title_api("set".into(), Some("herdr api".into())); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herdr api".to_string())) - ); - - server.handle_client_window_title_api("clear".into(), None); - assert_eq!(next_window_title(&control_rx), Some(None)); - - shutdown_test_runtimes(&mut server); - } - - #[test] - fn a_newly_promoted_client_gets_the_window_title_again() { - let (mut server, first_control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}"); - server.sync_window_title(); - assert_eq!( - next_window_title(&first_control_rx), - Some(Some("herd".to_string())) - ); - - // A second terminal starts on whatever its shell or ssh left behind. - let (client_tx, second_control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.promote_client_to_foreground(2); - server.sync_window_title(); - - assert_eq!( - next_window_title(&second_control_rx), - Some(Some("herd".to_string())) - ); - shutdown_test_runtimes(&mut server); - } - - fn test_client_writer() -> ( - ClientWriter, - std::sync::mpsc::Receiver>, - std::sync::mpsc::Receiver>, - ) { - let (control_tx, control_rx) = std::sync::mpsc::channel(); - let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1); - ( - ClientWriter::test_channel(control_tx, render_tx), - control_rx, - render_rx, - ) - } - - #[tokio::test] - async fn client_shell_attach_seeds_workspace_without_consuming_legacy_onboarding() { - let mut server = test_headless_server(); - server.app.state.workspaces.clear(); - server.app.state.active = None; - server.app.state.mode = crate::app::Mode::Onboarding; - let (writer, _control_rx, _render_rx) = test_client_writer(); - - assert!( - server.handle_server_event(ServerEvent::ClientShellConnected { - client_id: 6, - surface_cols: 80, - surface_rows: 23, - cell_width_px: 0, - cell_height_px: 0, - pixel_mouse: false, - direct_graphics: false, - endpoint_keybindings: false, - writer, - }) - ); - - assert_eq!(server.app.state.mode, crate::app::Mode::Onboarding); - assert_eq!(server.app.state.workspaces.len(), 1); - assert_eq!(server.app.state.active, Some(0)); - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn client_shell_endpoint_request_uses_the_selected_connection() { - let mut server = test_headless_server(); - let (writer, control_rx, _render_rx) = test_client_writer(); - let client_id = 41; - assert!( - server.handle_server_event(ServerEvent::ClientShellConnected { - client_id, - surface_cols: 80, - surface_rows: 23, - cell_width_px: 0, - cell_height_px: 0, - pixel_mouse: false, - direct_graphics: false, - endpoint_keybindings: false, - writer, - }) - ); - let _initial_snapshot = control_rx.recv().expect("initial shell snapshot"); - let boot_id = server.client_shell_boot_id.clone(); - - assert!( - !server.handle_server_event(ServerEvent::ClientShellEndpointRequest { - client_id, - boot_id: boot_id.clone(), - request: Box::new(api::schema::Request { - id: "client-shell:1".into(), - method: api::schema::Method::IntegrationList( - api::schema::EmptyParams::default(), - ), - }), - }) - ); - assert!(server.clients[&client_id].shell_endpoint_command_in_flight); - - let response_ready = server - .server_event_rx - .recv() - .await - .expect("endpoint response ready"); - assert!(!server.handle_server_event(response_ready)); - assert!(!server.clients[&client_id].shell_endpoint_command_in_flight); - - match read_server_message(control_rx.recv().expect("endpoint response")) { - ServerMessage::ClientShellEndpointResponseChunk { - boot_id: response_boot_id, - request_id, - final_chunk, - data, - } => { - assert_eq!(response_boot_id, boot_id); - assert_eq!(request_id, "client-shell:1"); - assert!(final_chunk); - let response = serde_json::from_slice::(&data) - .expect("success response"); - assert_eq!(response.id, "client-shell:1"); - assert!(matches!( - response.result, - api::schema::ResponseResult::IntegrationList { .. } - )); - } - other => panic!("expected client shell endpoint response, got {other:?}"), - } - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn client_shell_receives_metadata_then_shell_free_pane_surface() { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("shell-only-label"); - let pane_id = workspace.focused_pane_id().expect("focused pane"); - workspace.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - 80, - 23, - b"\x1b[?1003h\x1b[?1006h\x1b[?1016hCLIENT_SHELL_LIVE", - ), - ); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - server.app.state.product_announcement = Some(crate::app::state::ProductAnnouncementState { - version: "0.8.2".into(), - id: "client-shell".into(), - title: "Client shell".into(), - body: "announcement".into(), - scroll: 0, - preview: true, - }); - server.server_config_diagnostic_without_keybindings = - Some("endpoint config warning".into()); - - let (writer, control_rx, render_rx) = test_client_writer(); - assert!( - server.handle_server_event(ServerEvent::ClientShellConnected { - client_id: 7, - surface_cols: 80, - surface_rows: 23, - cell_width_px: 10, - cell_height_px: 20, - pixel_mouse: true, - direct_graphics: false, - endpoint_keybindings: false, - writer, - }) - ); - match read_server_message(control_rx.recv().expect("shell snapshot")) { - ServerMessage::ClientShellSnapshot(snapshot) => { - assert_eq!(snapshot.workspaces.len(), 1); - assert_eq!(snapshot.workspaces[0].label, "shell-only-label"); - assert_eq!( - snapshot.config_diagnostic.as_deref(), - Some("endpoint config warning") - ); - assert_eq!( - snapshot.product_announcement.as_ref().map(|announcement| ( - announcement.version.as_str(), - announcement.id.as_str(), - announcement.preview, - )), - Some(("0.8.2", "client-shell", true)) - ); - } - other => panic!("expected client shell snapshot, got {other:?}"), - } - - server.render_and_stream(); - match read_server_message(render_rx.recv().expect("pane surface")) { - ServerMessage::PaneSurface(surface) => { - assert_eq!((surface.frame.width, surface.frame.height), (80, 23)); - let text = frame_text(&surface.frame); - assert!(text.contains("CLIENT_SHELL_LIVE"), "surface: {text:?}"); - assert!(!text.contains("shell-only-label"), "surface: {text:?}"); - assert_eq!(surface.panes.len(), 1); - assert_eq!(surface.panes[0].rect.x, 0); - assert_eq!(surface.panes[0].rect.y, 0); - assert!(surface.panes[0].sgr_pixel_mouse); - assert_eq!( - surface.panes[0].pixel_width, - u32::from(surface.panes[0].inner_rect.width) * 10 - ); - assert_eq!( - surface.panes[0].pixel_height, - u32::from(surface.panes[0].inner_rect.height) * 20 - ); - } - other => panic!("expected pane surface, got {other:?}"), - } - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn client_shell_config_diagnostics_follow_keybinding_ownership() { - let mut server = test_headless_server(); - server.server_config_diagnostic = Some("server keybinding warning\ntheme warning".into()); - server.server_config_diagnostic_without_keybindings = Some("theme warning".into()); - - let (local_writer, local_control, _local_render) = test_client_writer(); - assert!( - server.handle_server_event(ServerEvent::ClientShellConnected { - client_id: 13, - surface_cols: 80, - surface_rows: 23, - cell_width_px: 0, - cell_height_px: 0, - pixel_mouse: false, - direct_graphics: false, - endpoint_keybindings: false, - writer: local_writer, - }) - ); - let ServerMessage::ClientShellSnapshot(local_snapshot) = - read_server_message(local_control.recv().expect("local shell snapshot")) - else { - panic!("expected local shell snapshot"); - }; - assert_eq!( - local_snapshot.config_diagnostic.as_deref(), - Some("theme warning") - ); - - let (endpoint_writer, endpoint_control, _endpoint_render) = test_client_writer(); - assert!( - server.handle_server_event(ServerEvent::ClientShellConnected { - client_id: 14, - surface_cols: 80, - surface_rows: 23, - cell_width_px: 0, - cell_height_px: 0, - pixel_mouse: false, - direct_graphics: false, - endpoint_keybindings: true, - writer: endpoint_writer, - }) - ); - let ServerMessage::ClientShellSnapshot(endpoint_snapshot) = - read_server_message(endpoint_control.recv().expect("endpoint shell snapshot")) - else { - panic!("expected endpoint shell snapshot"); - }; - assert_eq!( - endpoint_snapshot.config_diagnostic.as_deref(), - Some("server keybinding warning\ntheme warning") - ); - - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn client_shell_replaces_projection_and_focuses_stable_ids() { - let mut server = test_headless_server(); - let first = crate::workspace::Workspace::test_new("first"); - let second = crate::workspace::Workspace::test_new("second"); - server.app.state.workspaces = vec![first, second]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - let second_id = server.app.session_snapshot().workspaces[1] - .workspace_id - .clone(); - - let (writer, control_rx, render_rx) = test_client_writer(); - assert!( - server.handle_server_event(ServerEvent::ClientShellConnected { - client_id: 9, - surface_cols: 80, - surface_rows: 23, - cell_width_px: 0, - cell_height_px: 0, - pixel_mouse: false, - direct_graphics: false, - endpoint_keybindings: false, - writer, - }) - ); - let initial_revision = - match read_server_message(control_rx.recv().expect("initial snapshot")) { - ServerMessage::ClientShellSnapshot(snapshot) => snapshot.revision, - other => panic!("expected initial shell snapshot, got {other:?}"), - }; - - let _ = server - .app - .runtime_workspace_focus("test.client.shell.workspace.focus", second_id.clone()); - assert_eq!(server.app.state.active, Some(1)); - server.render_and_stream(); - - let replacement = - match read_server_message(control_rx.recv().expect("replacement snapshot")) { - ServerMessage::ClientShellSnapshot(snapshot) => snapshot, - other => panic!("expected replacement shell snapshot, got {other:?}"), - }; - assert!(replacement.revision > initial_revision); - assert_eq!( - replacement.focused_workspace_id.as_deref(), - Some(second_id.as_str()) - ); - match read_server_message(render_rx.recv().expect("replacement pane surface")) { - ServerMessage::PaneSurface(surface) => { - assert_eq!(surface.projection_revision, replacement.revision); - } - other => panic!("expected replacement pane surface, got {other:?}"), - } - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn client_shell_input_targets_runtime_without_server_shell_classification() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1000h\x1b[?1006h"); - let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); - server.clients.insert( - 11, - ClientConnection::new_with_mode( - ClientConnectionMode::ClientShell, - None, - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - false, - None, - ), - ); - - assert!( - server.handle_server_event(ServerEvent::ClientShellPaneInput { - client_id: 11, - pane_id, - events: vec![ - crate::protocol::ClientPaneInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 1, - shifted_codepoint: None, - generated_text: None, - }, - crate::protocol::ClientPaneInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), - kind: crate::protocol::ClientKeyKind::Release, - repeat_count: 1, - shifted_codepoint: None, - generated_text: None, - }, - crate::protocol::ClientPaneInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), - modifiers: crossterm::event::KeyModifiers::ALT.bits(), - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 1, - shifted_codepoint: None, - generated_text: None, - }, - crate::protocol::ClientPaneInputEvent::Mouse { - kind: crate::protocol::ClientMouseKind::Down( - crate::protocol::ClientMouseButton::Left, - ), - position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, - modifiers: 0, - lines: 3, - }, - ], - }) - ); - assert_eq!( - input_rx.try_recv().expect("targeted pane interrupt"), - Bytes::from_static(&[0x03]) - ); - assert_eq!( - input_rx.try_recv().expect("targeted pane alt key"), - Bytes::from_static(b"\x1bx") - ); - assert_eq!( - input_rx.try_recv().expect("targeted pane mouse click"), - Bytes::from_static(b"\x1b[<0;3;2M") - ); - assert_eq!(server.foreground_client_id, Some(11)); - let (workspace_index, runtime_pane_id) = server - .app - .parse_pane_id( - server - .app - .session_snapshot() - .focused_pane_id - .as_deref() - .expect("focused pane id"), - ) - .expect("runtime pane target"); - let runtime = server - .app - .state - .runtime_for_pane_in_workspace( - &server.app.terminal_runtimes, - workspace_index, - runtime_pane_id, - ) - .expect("focused runtime"); - assert_eq!(runtime.current_size(), (24, 79)); - assert!(input_rx.try_recv().is_err(), "legacy release emitted bytes"); - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn client_shell_streams_and_targets_popup_terminal_content() { - let mut server = test_headless_server(); - let mut pane_input = install_focused_test_runtime(&mut server, b"base-pane"); - let (popup_runtime, mut popup_input) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 40, - 12, - 0, - b"POPUP_SHELL_LIVE\x1b_Ga=T,f=32,t=d,i=9,p=4,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", - 4, - ); - let (_, popup_terminal_id) = server.app.install_test_popup_runtime(popup_runtime); - - let (writer, control_rx, render_rx) = test_client_writer(); - assert!( - server.handle_server_event(ServerEvent::ClientShellConnected { - client_id: 12, - surface_cols: 80, - surface_rows: 23, - cell_width_px: 10, - cell_height_px: 20, - pixel_mouse: false, - direct_graphics: false, - endpoint_keybindings: false, - writer, - }) - ); - assert!(matches!( - read_server_message(control_rx.recv().expect("shell snapshot")), - ServerMessage::ClientShellSnapshot(_) - )); - - server.render_and_stream(); - let ServerMessage::PaneSurface(surface) = - read_server_message(render_rx.recv().expect("popup surface")) - else { - panic!("expected pane surface"); - }; - let popup = surface.popup.as_deref().expect("popup terminal surface"); - assert_eq!(popup.terminal_id, popup_terminal_id.as_str()); - assert!(frame_text(&popup.frame).contains("POPUP_SHELL_LIVE")); - assert_eq!((popup.frame.width, popup.frame.height), (37, 9)); - assert_eq!(surface.graphics.assets.len(), 1); - assert_eq!(surface.graphics.placements.len(), 1); - assert!(matches!( - surface.graphics.placements[0].asset.source, - crate::protocol::SurfaceGraphicsSource::Terminal { - target: crate::protocol::SurfaceGraphicsTarget::Popup { .. }, - image_id: 9, - } - )); - - assert!( - !server.handle_server_event(ServerEvent::ClientShellPaneInput { - client_id: 12, - pane_id: server.app.session_snapshot().focused_pane_id.unwrap(), - events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( - "must-not-leak".into(), - )], - }) - ); - assert!(pane_input.try_recv().is_err()); - - assert!(server.handle_server_event(ServerEvent::ClientShellResize { - client_id: 12, - surface_cols: 60, - surface_rows: 15, - cell_width_px: 0, - cell_height_px: 0, - })); - assert_eq!( - server - .app - .terminal_runtimes - .get(&popup_terminal_id) - .expect("popup runtime") - .current_size(), - (5, 27) - ); - - assert!( - server.handle_server_event(ServerEvent::ClientShellPopupInput { - client_id: 12, - terminal_id: popup_terminal_id.to_string(), - events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( - "typed".into() - )], - }) - ); - assert_eq!( - popup_input.try_recv().expect("popup input"), - Bytes::from_static(b"typed") - ); - assert!( - !server.handle_server_event(ServerEvent::ClientShellPopupInput { - client_id: 12, - terminal_id: "stale-popup".into(), - events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( - "wrong".into() - )], - }) - ); - assert!(popup_input.try_recv().is_err()); - - assert!(server.app.close_popup_pane()); - server.render_and_stream(); - let ServerMessage::PaneSurface(surface) = - read_server_message(render_rx.recv().expect("popup close surface")) - else { - panic!("expected pane surface after popup close"); - }; - assert!(surface.popup.is_none()); - shutdown_test_runtimes(&mut server); - } - - fn retained_test_server( - initial_screen: &[u8], - ) -> ( - HeadlessServer, - std::sync::mpsc::Receiver>, - crate::layout::PaneId, - ) { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let pane_id = workspace.focused_pane_id().expect("focused pane"); - workspace.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, initial_screen), - ); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - (server, client_rx, pane_id) - } - - fn hidden_pty_visibility_test_server( - client_sizes: &[(u16, u16)], - ) -> (HeadlessServer, crate::layout::PaneId) { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let background_tab = workspace.test_add_tab(Some("background")); - let background_pane = workspace.tabs[background_tab].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - for (index, &terminal_size) in client_sizes.iter().enumerate() { - let client_id = index as u64 + 1; - let (client_tx, _client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - client_id, - ClientConnection::new( - terminal_size, - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - client_id, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - } - - (server, background_pane) - } - - fn assert_frame_data_eq(actual: &FrameData, expected: &FrameData) { - assert_eq!( - (actual.width, actual.height), - (expected.width, expected.height) - ); - assert_eq!(actual.cursor, expected.cursor, "cursor mismatch"); - assert_eq!(actual.hyperlinks, expected.hyperlinks, "hyperlink mismatch"); - assert_eq!(actual.graphics, expected.graphics, "graphics mismatch"); - assert_eq!( - actual.cells.len(), - expected.cells.len(), - "cell length mismatch" - ); - for (idx, (actual_cell, expected_cell)) in - actual.cells.iter().zip(expected.cells.iter()).enumerate() - { - if cells_equivalent_for_frame_compare( - &actual.cells, - &expected.cells, - usize::from(actual.width), - idx, - actual_cell, - expected_cell, - ) { - continue; - } - assert_eq!( - actual_cell, - expected_cell, - "cell mismatch at index {idx} (x={}, y={})", - idx % usize::from(actual.width), - idx / usize::from(actual.width), - ); - } - } - - fn cells_equivalent_for_frame_compare( - actual_cells: &[CellData], - expected_cells: &[CellData], - width: usize, - idx: usize, - actual: &CellData, - expected: &CellData, - ) -> bool { - if actual == expected { - return true; - } - if !cell_style_without_symbol_eq(actual, expected) { - return false; - } - if !matches!( - (actual.symbol.as_str(), expected.symbol.as_str()), - ("", " ") | (" ", "") - ) { - return false; - } - covered_by_previous_wide_cell(actual_cells, width, idx) - || covered_by_previous_wide_cell(expected_cells, width, idx) - } - - fn cell_style_without_symbol_eq(a: &CellData, b: &CellData) -> bool { - a.fg == b.fg - && a.bg == b.bg - && a.modifier == b.modifier - && a.skip == b.skip - && a.hyperlink == b.hyperlink - } - - fn covered_by_previous_wide_cell(cells: &[CellData], width: usize, idx: usize) -> bool { - if idx == 0 || idx.is_multiple_of(width) { - return false; - } - frame_cell_display_width(&cells[idx - 1]) > 1 - } - - fn frame_cell_display_width(cell: &CellData) -> usize { - if is_halfwidth_katakana_voiced_grapheme(&cell.symbol) { - return 2; - } - cell.symbol.width() - } - - fn is_halfwidth_katakana_voiced_grapheme(symbol: &str) -> bool { - let mut chars = symbol.chars(); - let Some(base) = chars.next() else { - return false; - }; - let Some(mark) = chars.next() else { - return false; - }; - chars.next().is_none() - && ('\u{ff66}'..='\u{ff9d}').contains(&base) - && matches!(mark, '\u{ff9e}' | '\u{ff9f}') - } - - #[test] - fn direct_graphics_requires_one_negotiated_app_client() { - let mut server = test_headless_server(); - let (writer_a, _control_a, _render_a) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 1, - cols: 80, - rows: 24, - cell_width_px: 10, - cell_height_px: 20, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: true, - writer: writer_a, - })); - assert!(server.clients[&1].direct_graphics); - assert!(server.clients[&1].pixel_mouse); - assert!(server.direct_graphics_available()); - - let (writer_b, _control_b, _render_b) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 2, - cols: 80, - rows: 24, - cell_width_px: 10, - cell_height_px: 20, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer: writer_b, - })); - assert!(!server.direct_graphics_available()); - } - - #[test] - fn foreground_client_applies_client_keybindings() { - let mut server = test_headless_server(); - let local_config: crate::config::Config = toml::from_str( - r#" -[keys] -prefix = "ctrl+a" -new_tab = "prefix+t" -"#, - ) - .unwrap(); - let local_keybindings = local_config.live_keybinds().unwrap(); - let (writer_a, _control_a, _render_a) = test_client_writer(); - let (writer_b, _control_b, _render_b) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 1, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: Some(Box::new(local_keybindings)), - direct_attach_requested: false, - direct_graphics: false, - writer: writer_a, - })); - assert_eq!( - server.app.state.prefix_code, - crossterm::event::KeyCode::Char('a') - ); - assert!(server - .app - .state - .keybinds - .new_tab - .bindings - .iter() - .any(|binding| binding.label == "prefix+t")); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 2, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer: writer_b, - })); - assert_eq!( - server.app.state.prefix_code, - crossterm::event::KeyCode::Char('b') - ); - assert!(server - .app - .state - .keybinds - .new_tab - .bindings - .iter() - .any(|binding| binding.label == "prefix+c")); - } - - #[test] - fn server_keybinding_filter_keeps_whole_config_failures() { - assert!(!config::is_keybinding_config_diagnostic( - "config parse error: invalid value at `keys.new_tab = @`; using defaults" - )); - assert!(!config::is_keybinding_config_diagnostic( - "config read error: permission denied at keys.toml; using defaults" - )); - assert!(config::is_keybinding_config_diagnostic( - "unsafe direct keybinding: keys.close_pane would intercept typing" - )); - } - - #[test] - fn local_keybinding_client_hides_server_keybinding_warnings() { - let mut server = test_headless_server(); - let diagnostics = vec![ - "unsafe direct keybinding: keys.close_pane = \"x\" would intercept typing".to_owned(), - "theme warning".to_owned(), - ]; - let (full, without_keybindings) = server_config_diagnostic_summaries(&diagnostics); - server.server_config_diagnostic = full.clone(); - server.server_config_diagnostic_without_keybindings = without_keybindings.clone(); - server.app.state.config_diagnostic = full; - let local_keybindings = crate::config::Config::default().live_keybinds().unwrap(); - let (writer_a, _control_a, _render_a) = test_client_writer(); - let (writer_b, _control_b, _render_b) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 1, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: Some(Box::new(local_keybindings)), - direct_attach_requested: false, - direct_graphics: false, - writer: writer_a, - })); - assert_eq!(server.app.state.config_diagnostic, without_keybindings); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 2, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer: writer_b, - })); - assert_eq!( - server.app.state.config_diagnostic, - server.server_config_diagnostic - ); - } - - #[test] - fn local_keybinding_client_keeps_local_keybindings_after_settings_save() { - let path = std::env::temp_dir().join(format!( - "herdr-headless-settings-{}-{}.toml", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - std::fs::write(&path, "onboarding = false\n").unwrap(); - let _guard = crate::config::test_config_env_lock().lock().unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut server = test_headless_server(); - let local_config: crate::config::Config = toml::from_str( - r#" -[keys] -prefix = "ctrl+a" -new_workspace = "prefix+n" -next_tab = "" -"#, - ) - .unwrap(); - let local_keybindings = local_config.live_keybinds().unwrap(); - let (writer, _control, _render) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 1, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: Some(Box::new(local_keybindings)), - direct_attach_requested: false, - direct_graphics: false, - writer, - })); - server.app.state.mode = crate::app::Mode::Settings; - server.app.state.settings.section = crate::app::state::SettingsSection::Toast; - server.app.state.settings.list.selected = 1; - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\r".to_vec(), - })); - - assert_eq!( - server.app.state.prefix_code, - crossterm::event::KeyCode::Char('a') - ); - assert!(server - .app - .state - .keybinds - .new_workspace - .bindings - .iter() - .any(|binding| binding.label == "prefix+n")); - assert!(server.app.state.toast.is_none()); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("delivery = \"herdr\"")); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_file(path); - } - - #[test] - fn invalid_server_keybindings_apply_valid_subset_after_settings_save_without_caching_local_keybindings( - ) { - let path = std::env::temp_dir().join(format!( - "herdr-headless-invalid-settings-{}-{}.toml", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - std::fs::write( - &path, - "onboarding = false\n[keys]\nnew_workspace = \"x\"\n[ui.toast]\ndelivery = \"off\"\n", - ) - .unwrap(); - let _guard = crate::config::test_config_env_lock().lock().unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut server = test_headless_server(); - let previous_server_config: crate::config::Config = - toml::from_str("[keys]\nprefix = \"ctrl+c\"\nnew_workspace = \"prefix+m\"\n").unwrap(); - server.server_keybindings = previous_server_config.live_keybinds().unwrap(); - let local_config: crate::config::Config = toml::from_str( - r#" -[keys] -prefix = "ctrl+a" -new_workspace = "prefix+n" -next_tab = "" -"#, - ) - .unwrap(); - let (writer_a, _control_a, _render_a) = test_client_writer(); - let (writer_b, _control_b, _render_b) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 1, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: Some(Box::new(local_config.live_keybinds().unwrap())), - direct_attach_requested: false, - direct_graphics: false, - writer: writer_a, - })); - server.app.state.mode = crate::app::Mode::Settings; - server.app.state.settings.section = crate::app::state::SettingsSection::Toast; - server.app.state.settings.list.selected = 1; - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\r".to_vec(), - })); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 2, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer: writer_b, - })); - assert_eq!( - server.app.state.prefix_code, - crossterm::event::KeyCode::Char('b') - ); - assert!(!server - .app - .state - .keybinds - .new_workspace - .bindings - .iter() - .any(|binding| binding.label == "prefix+n")); - assert!(server.app.state.keybinds.new_workspace.bindings.is_empty()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_file(path); - } - - #[test] - fn terminal_attach_rejects_missing_terminal_and_removes_client() { - let mut server = test_headless_server(); - let (writer, control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::TerminalAnsi, - keybindings: None, - direct_attach_requested: true, - direct_graphics: false, - writer, - })); - assert!(server.clients.contains_key(&7)); - - assert!( - !server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 7, - terminal_id: "term_missing".to_owned(), - takeover: false, - }) - ); - assert!(!server.clients.contains_key(&7)); - let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); - assert_eq!( - reason, - Some("terminal attach failed: terminal term_missing not found".to_owned()) - ); - } - - fn with_terminal_session_test_server( - test: impl FnOnce(&mut HeadlessServer, crate::terminal::TerminalId, String, String), - ) { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("test"); - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); - let terminal_id_string = terminal_id.to_string(); - let public_pane_id = format!("{}:p1", workspace.id); - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - server.app.terminal_runtimes.insert( - terminal_id.clone(), - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), - ); - - test(&mut server, terminal_id, terminal_id_string, public_pane_id); - - drop(server); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - fn connect_pending_terminal_client(server: &mut HeadlessServer, client_id: u64) { - let _control_rx = connect_pending_terminal_client_with_control_rx(server, client_id); - } - - fn connect_pending_terminal_client_with_control_rx( - server: &mut HeadlessServer, - client_id: u64, - ) -> std::sync::mpsc::Receiver> { - let (writer, control_rx, _render_rx) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id, - cols: 100, - rows: 30, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::TerminalAnsi, - keybindings: None, - direct_attach_requested: true, - direct_graphics: false, - writer, - })); - control_rx - } - - #[test] - fn explicit_agent_history_read_requires_idle_on_alternate_screen() { - with_terminal_session_test_server( - |server, terminal_id, _terminal_id_string, public_pane_id| { - let terminal = server - .app - .state - .terminals - .get_mut(&terminal_id) - .expect("terminal"); - terminal.detected_agent = Some(crate::detect::Agent::Claude); - terminal.state = crate::detect::AgentState::Working; - server.app.terminal_runtimes.insert( - terminal_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - 80, - 24, - b"\x1b[?1049hworking", - ), - ); - let request = api::schema::Request { - id: "read".into(), - method: api::schema::Method::AgentRead(api::schema::AgentReadParams { - target: public_pane_id.clone(), - source: api::schema::ReadSource::Recent, - lines: Some(200), - format: api::schema::ReadFormat::Text, - strip_ansi: true, - }), - }; - - assert_eq!( - server.agent_read_not_idle_error(&request), - Some(api::schema::ErrorBody { - code: "agent_not_idle".into(), - message: format!( - "cannot read 200 lines while {public_pane_id} is working: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible" - ), - }) - ); - - let mut default_request = request.clone(); - let api::schema::Method::AgentRead(params) = &mut default_request.method else { - unreachable!(); - }; - params.lines = None; - assert_eq!(server.agent_read_not_idle_error(&default_request), None); - - let mut visible_request = request; - let api::schema::Method::AgentRead(params) = &mut visible_request.method else { - unreachable!(); - }; - params.source = api::schema::ReadSource::Visible; - assert_eq!(server.agent_read_not_idle_error(&visible_request), None); - }, - ); - } - - #[test] - fn terminal_observe_allows_multiple_clients_without_attach_ownership() { - with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { - let initial_size = server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .current_size(); - - for client_id in [7, 8] { - connect_pending_terminal_client(server, client_id); - assert!( - server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id, - target: terminal_id_string.clone(), - }) - ); - } - - assert!(server.terminal_attach_owners.is_empty()); - assert!(!server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - assert_eq!( - server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .current_size(), - initial_size - ); - assert_eq!( - terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), - 2 - ); - }); - } - - #[test] - fn terminal_observe_resolves_public_pane_id() { - with_terminal_session_test_server(|server, terminal_id, _, public_pane_id| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id: 7, - target: public_pane_id, - }) - ); - - assert!(matches!( - server.clients.get(&7).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalObserve { terminal_id: observed }) - if observed == &terminal_id.to_string() - )); - }); - } - - #[test] - fn terminal_control_resolves_public_pane_id_and_takes_ownership() { - with_terminal_session_test_server( - |server, terminal_id, terminal_id_string, public_pane_id| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: public_pane_id, - takeover: false, - }) - ); - - assert!(matches!( - server.clients.get(&7).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalAttach { terminal_id: attached }) - if attached == &terminal_id_string - )); - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&7) - ); - assert!(server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - }, - ); - } - - #[test] - fn terminal_control_rejects_attach_during_alt_screen_read() { - with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { - let (respond_to, _response_rx) = std::sync::mpsc::channel(); - server.pending_alt_screen_reads.push( - crate::server::alt_screen_read::PendingAltScreenRead::start( - terminal_id, - "read".into(), - respond_to, - "fallback".into(), - api::schema::PaneReadResult { - pane_id: "w1:p1".into(), - workspace_id: "w1".into(), - tab_id: "w1:t1".into(), - source: api::schema::ReadSource::Recent, - format: api::schema::ReadFormat::Text, - text: String::new(), - revision: 0, - truncated: false, - }, - 120, - false, - crate::terminal::ScreenSnapshot { - cols: 80, - rows: Vec::new(), - }, - 0, - Instant::now(), - ), - ); - let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); - - assert!( - !server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - assert!(!server.clients.contains_key(&7)); - assert!(!server - .terminal_attach_owners - .contains_key(&terminal_id_string)); - let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); - assert_eq!( - reason, - Some(format!( - "terminal attach failed: terminal {terminal_id_string} has a read in progress; retry" - )) - ); - }); - } - - #[test] - fn terminal_control_rejects_second_controller_without_takeover() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - connect_pending_terminal_client(server, 8); - assert!( - !server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 8, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - assert!(server.clients.contains_key(&7)); - assert!(!server.clients.contains_key(&8)); - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&7) - ); - }); - } - - #[test] - fn terminal_control_takeover_replaces_existing_controller() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - connect_pending_terminal_client(server, 8); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 8, - target: terminal_id_string.clone(), - takeover: true, - }) - ); - - assert!(!server.clients.contains_key(&7)); - assert!(server.clients.contains_key(&8)); - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&8) - ); - }); - } - - #[test] - fn terminal_observe_can_coexist_with_terminal_control() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - connect_pending_terminal_client(server, 8); - assert!( - server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id: 8, - target: terminal_id_string.clone(), - }) - ); - - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&7) - ); - assert!(matches!( - server.clients.get(&8).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalObserve { terminal_id }) - if terminal_id == &terminal_id_string - )); - assert_eq!( - terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), - 2 - ); - }); - } - - #[test] - fn terminal_control_detach_sends_shutdown_before_removal() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { - let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - assert!(server.handle_server_event(ServerEvent::ClientDetach { client_id: 7 })); - - assert!(!server.clients.contains_key(&7)); - assert!(!server - .terminal_attach_owners - .contains_key(&terminal_id_string)); - let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); - assert_eq!(reason, Some("detached".to_owned())); - }); - } - - #[test] - fn terminal_observe_rejects_later_attach_upgrade() { - with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id: 7, - target: terminal_id_string.clone(), - }) - ); - assert!( - !server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 7, - terminal_id: terminal_id_string, - takeover: true, - }) - ); - - assert!(!server.clients.contains_key(&7)); - assert!(server.terminal_attach_owners.is_empty()); - assert!(!server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - }); - } - - #[test] - fn terminal_attach_rejects_later_observe_and_clears_ownership() { - with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 7, - terminal_id: terminal_id_string.clone(), - takeover: false, - }) - ); - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&7) - ); - assert!(server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - - assert!( - !server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id: 7, - target: terminal_id_string.clone(), - }) - ); - - assert!(!server.clients.contains_key(&7)); - assert!(server.terminal_attach_owners.is_empty()); - assert!(!server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - }); - } - - fn app_client_marks_git_refresh_due_on_first_attach(render_encoding: RenderEncoding) { - let mut server = test_headless_server(); - server - .app - .state - .workspaces - .push(crate::workspace::Workspace::test_new("test")); - let future = Instant::now() + Duration::from_secs(60); - server.app.last_git_remote_status_refresh = future; - let (writer, _control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer, - })); - - assert!(server.has_app_client()); - assert!(server - .app - .git_refresh_deadline() - .is_some_and(|deadline| deadline <= Instant::now())); - } - - #[test] - fn terminal_ansi_app_client_enables_headless_git_refresh() { - app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::TerminalAnsi); - } - - #[test] - fn pending_terminal_attach_client_does_not_enable_headless_git_refresh() { - let mut server = test_headless_server(); - server - .app - .state - .workspaces - .push(crate::workspace::Workspace::test_new("test")); - let (writer, _control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::TerminalAnsi, - keybindings: None, - direct_attach_requested: true, - direct_graphics: false, - writer, - })); - - assert!(!server.has_app_client()); - assert_eq!( - server.app.next_headless_loop_deadline_with_git_refresh( - Instant::now(), - false, - server.has_app_client() - ), - None - ); - } - - #[test] - fn writerless_app_client_does_not_enable_headless_git_refresh() { - let mut server = test_headless_server(); - server - .app - .state - .workspaces - .push(crate::workspace::Workspace::test_new("test")); - let (writer, _control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer, - })); - assert!(server.has_app_client()); - - server.clients.get_mut(&7).expect("client").writer = None; - - assert!(!server.has_app_client()); - assert_eq!( - server.app.next_headless_loop_deadline_with_git_refresh( - Instant::now(), - false, - server.has_app_client() - ), - None - ); - } - - #[test] - fn semantic_app_client_marks_git_refresh_due_on_first_attach() { - app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::SemanticFrame); - } - - #[test] - fn unchanged_git_refresh_does_not_request_headless_render() { - let mut server = test_headless_server(); - server.app.git_refresh_in_flight = true; - let mut workspace = crate::workspace::Workspace::test_new("one"); - let workspace_id = workspace.id.clone(); - let cwd = workspace.identity_cwd.clone(); - workspace.cached_auto_label = "cached".into(); - workspace.cached_git_status_key = cwd.clone(); - workspace.cached_git_branch = None; - server.app.state.workspaces.push(workspace); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed { - results: vec![crate::workspace::WorkspaceGitStatus { - workspace_id, - resolved_identity_cwd: cwd.clone(), - status_cache_key: cwd, - demand: crate::workspace::GitStatusRefreshDemand::ALL, - auto_label: "cached".into(), - branch: None, - ahead_behind: None, - space: None, - }], - cache_updates: Vec::new(), - }); - - assert!(!changed); - assert!(!server.app.git_refresh_in_flight); - } - - #[test] - fn changed_git_refresh_requests_headless_render() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("one"); - let workspace_id = workspace.id.clone(); - let cwd = workspace.identity_cwd.clone(); - server.app.state.workspaces.push(workspace); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed { - results: vec![crate::workspace::WorkspaceGitStatus { - workspace_id, - resolved_identity_cwd: cwd.clone(), - status_cache_key: cwd, - demand: crate::workspace::GitStatusRefreshDemand::ALL, - auto_label: "one".into(), - branch: Some("changed".into()), - ahead_behind: None, - space: None, - }], - cache_updates: Vec::new(), - }); - - assert!(changed); - } - - #[test] - fn terminal_attach_client_exits_when_attached_pane_dies() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("attached"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - let terminal_id = server.app.state.workspaces[0] - .pane_state(pane_id) - .expect("pane") - .attached_terminal_id - .to_string(); - let (writer, control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::TerminalAnsi, - keybindings: None, - direct_attach_requested: true, - direct_graphics: false, - writer, - })); - assert!( - server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 7, - terminal_id: terminal_id.clone(), - takeover: false, - }) - ); - assert_eq!(server.terminal_attach_owners.get(&terminal_id), Some(&7)); - - assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id })); - - assert!(!server.clients.contains_key(&7)); - assert!(!server.terminal_attach_owners.contains_key(&terminal_id)); - let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); - assert_eq!(reason, Some(format!("terminal {terminal_id} exited"))); - } - - #[test] - fn terminal_attach_scroll_moves_attached_runtime_viewport() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut bytes = Vec::new(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let runtime = - crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); - - apply_terminal_attach_scroll( - &runtime, - AttachScrollSource::Wheel, - AttachScrollDirection::Up, - 3, - None, - None, - 0, - ) - .expect("scroll up"); - let metrics = runtime.scroll_metrics().expect("scroll metrics"); - assert_eq!(metrics.offset_from_bottom, 3); - - apply_terminal_attach_scroll( - &runtime, - AttachScrollSource::Wheel, - AttachScrollDirection::Down, - 2, - None, - None, - 0, - ) - .expect("scroll down"); - let metrics = runtime.scroll_metrics().expect("scroll metrics"); - assert_eq!(metrics.offset_from_bottom, 1); - drop(runtime); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - #[test] - fn client_pane_pixel_mouse_uses_runtime_pixel_encoding() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 20, - 5, - 0, - b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", - 4, - ); - runtime.resize(5, 20, 10, 20); - - apply_client_pane_input_events( - &runtime, - &[crate::protocol::ClientPaneInputEvent::Mouse { - kind: crate::protocol::ClientMouseKind::Moved, - position: crate::protocol::ClientMousePosition::Pixels { - x: 21, - y: 22, - column: 2, - row: 1, - }, - modifiers: 0, - lines: 3, - }], - ) - .expect("pixel mouse input"); - assert_eq!( - input_rx.try_recv().expect("encoded pixel mouse"), - Bytes::from_static(b"\x1b[<35;21;22M") - ); - drop(runtime); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - #[test] - fn client_pane_pixel_mouse_falls_back_to_canonical_cell_position() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 20, - 5, - 0, - b"\x1b[?1003h\x1b[?1006h", - 4, - ); - runtime.resize(5, 20, 10, 20); - - apply_client_pane_input_events( - &runtime, - &[crate::protocol::ClientPaneInputEvent::Mouse { - kind: crate::protocol::ClientMouseKind::Moved, - position: crate::protocol::ClientMousePosition::Pixels { - x: 21, - y: 22, - column: 2, - row: 1, - }, - modifiers: 0, - lines: 3, - }], - ) - .expect("cell mouse fallback"); - assert_eq!( - input_rx.try_recv().expect("encoded cell mouse"), - Bytes::from_static(b"\x1b[<35;3;2M") - ); - drop(runtime); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - #[test] - fn client_pane_wheel_input_accumulates_scrollback_offset() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut bytes = Vec::new(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 20, 5, 4096, &bytes, 4, - ); - let scroll = |kind| crate::protocol::ClientPaneInputEvent::Mouse { - kind, - position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, - modifiers: 0, - lines: 3, - }; - - apply_client_pane_input_events( - &runtime, - &[scroll(crate::protocol::ClientMouseKind::ScrollUp)], - ) - .expect("first scroll up"); - apply_client_pane_input_events( - &runtime, - &[scroll(crate::protocol::ClientMouseKind::ScrollUp)], - ) - .expect("second scroll up"); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 6 - ); - - apply_client_pane_input_events( - &runtime, - &[scroll(crate::protocol::ClientMouseKind::ScrollDown)], - ) - .expect("scroll down"); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 3 - ); - - runtime.test_process_pty_bytes(b"\x1b[?1003h\x1b[?1006h"); - apply_client_pane_input_events( - &runtime, - &[crate::protocol::ClientPaneInputEvent::Mouse { - kind: crate::protocol::ClientMouseKind::Moved, - position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, - modifiers: 0, - lines: 3, - }], - ) - .expect("reported mouse motion"); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 3 - ); - assert_eq!( - input_rx.try_recv().expect("reported mouse motion"), - Bytes::from_static(b"\x1b[<35;3;2M") - ); - - apply_client_pane_input_events( - &runtime, - &[crate::protocol::ClientPaneInputEvent::Mouse { - kind: crate::protocol::ClientMouseKind::Down( - crate::protocol::ClientMouseButton::Left, - ), - position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, - modifiers: 0, - lines: 3, - }], - ) - .expect("mouse button"); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("reported mouse button"), - Bytes::from_static(b"\x1b[<0;3;2M") - ); - drop(runtime); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - #[test] - fn terminal_attach_input_resets_scrolled_viewport() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut bytes = Vec::new(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 20, 5, 4096, &bytes, 4, - ); - - runtime.scroll_up(4); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 4 - ); - - apply_terminal_attach_input(&runtime, b"x".to_vec()).expect("attach input"); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("forwarded input"), - Bytes::from("x") - ); - - drop(runtime); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - fn with_terminal_attach_runtime( - initial_bytes: &[u8], - initial_scroll: usize, - test: impl FnOnce(&crate::terminal::TerminalRuntime, &mut mpsc::Receiver), - ) { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut bytes = initial_bytes.to_vec(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 20, 5, 4096, &bytes, 4, - ); - if initial_scroll > 0 { - runtime.scroll_up(initial_scroll); - } - - test(&runtime, &mut input_rx); - - drop(runtime); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - fn apply_terminal_attach_page_up(runtime: &crate::terminal::TerminalRuntime) { - apply_terminal_attach_scroll( - runtime, - AttachScrollSource::PageKey { - input: b"\x1b[5~".to_vec(), - }, - AttachScrollDirection::Up, - 4, - None, - None, - 0, - ) - .expect("page key"); - } - - fn client_page_key( - code: crate::protocol::ClientKeyCode, - modifiers: crossterm::event::KeyModifiers, - kind: crate::protocol::ClientKeyKind, - ) -> crate::protocol::ClientPaneInputEvent { - crate::protocol::ClientPaneInputEvent::Key { - code, - modifiers: modifiers.bits(), - kind, - repeat_count: 1, - shifted_codepoint: None, - generated_text: None, - } - } - - #[test] - fn client_plain_page_keys_scroll_shell_transcript_by_pane_height() { - with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { - apply_client_pane_input_events( - runtime, - &[client_page_key( - crate::protocol::ClientKeyCode::PageUp, - crossterm::event::KeyModifiers::empty(), - crate::protocol::ClientKeyKind::Press, - )], - ) - .expect("pane PageUp"); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 5 - ); - - apply_client_pane_input_events( - runtime, - &[client_page_key( - crate::protocol::ClientKeyCode::PageUp, - crossterm::event::KeyModifiers::empty(), - crate::protocol::ClientKeyKind::Release, - )], - ) - .expect("pane PageUp release"); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 5 - ); - - apply_client_pane_input_events( - runtime, - &[client_page_key( - crate::protocol::ClientKeyCode::PageDown, - crossterm::event::KeyModifiers::empty(), - crate::protocol::ClientKeyKind::Press, - )], - ) - .expect("pane PageDown"); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert!(input_rx.try_recv().is_err(), "page keys reached the shell"); - }); - } - - #[test] - fn client_page_keys_forward_when_modified_or_owned_by_application() { - with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { - apply_client_pane_input_events( - runtime, - &[client_page_key( - crate::protocol::ClientKeyCode::PageUp, - crossterm::event::KeyModifiers::CONTROL, - crate::protocol::ClientKeyKind::Press, - )], - ) - .expect("modified pane PageUp"); - assert!( - input_rx.try_recv().is_ok(), - "modified PageUp was not forwarded" - ); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - }); - - with_terminal_attach_runtime(b"\x1b[?1h", 0, |runtime, input_rx| { - apply_client_pane_input_events( - runtime, - &[client_page_key( - crate::protocol::ClientKeyCode::PageUp, - crossterm::event::KeyModifiers::empty(), - crate::protocol::ClientKeyKind::Press, - )], - ) - .expect("application PageUp"); - assert_eq!( - input_rx.try_recv().expect("forwarded application PageUp"), - Bytes::from_static(b"\x1b[5~") - ); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - }); - } - - #[test] - fn client_popup_plain_page_key_remains_popup_input() { - with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { - apply_client_popup_input_events( - runtime, - &[client_page_key( - crate::protocol::ClientKeyCode::PageUp, - crossterm::event::KeyModifiers::empty(), - crate::protocol::ClientKeyKind::Press, - )], - ) - .expect("popup PageUp"); - assert_eq!( - input_rx.try_recv().expect("forwarded popup PageUp"), - Bytes::from_static(b"\x1b[5~") - ); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - }); - } - - #[test] - fn terminal_attach_paste_uses_plain_text_when_runtime_did_not_enable_brackets() { - with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { - apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) - .expect("attach paste"); - - assert_eq!( - input_rx.try_recv().expect("forwarded paste"), - Bytes::from_static(b"line one\nline two") - ); - }); - } - - #[test] - fn terminal_attach_paste_preserves_brackets_when_runtime_enabled_them() { - with_terminal_attach_runtime(b"\x1b[?2004h", 0, |runtime, input_rx| { - apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) - .expect("attach paste"); - - assert_eq!( - input_rx.try_recv().expect("forwarded paste"), - Bytes::from_static(b"\x1b[200~line one\nline two\x1b[201~") - ); - }); - } - - #[test] - fn terminal_attach_page_key_host_scrolls_plain_terminal() { - with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 4 - ); - assert!(input_rx.try_recv().is_err()); - }); - } - - #[test] - fn terminal_attach_page_key_forwards_when_mouse_reporting() { - with_terminal_attach_runtime(b"\x1b[?1000h", 3, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("forwarded page key"), - Bytes::from_static(b"\x1b[5~") - ); - }); - } - - #[test] - fn terminal_attach_page_key_forwards_when_application_cursor() { - with_terminal_attach_runtime(b"\x1b[?1h", 3, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("forwarded page key"), - Bytes::from_static(b"\x1b[5~") - ); - }); - } - - #[test] - fn terminal_attach_page_key_host_scrolls_shell_like_decckm_with_bracketed_paste() { - with_terminal_attach_runtime(b"\x1b[?1h\x1b[?2004h", 0, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 4 - ); - assert!(input_rx.try_recv().is_err()); - }); - } - - #[test] - fn terminal_attach_page_key_forwards_in_alternate_screen_without_mouse_reporting() { - with_terminal_attach_runtime(b"\x1b[?1049h", 3, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("forwarded page key"), - Bytes::from_static(b"\x1b[5~") - ); - }); - } - - #[test] - fn headless_scheduled_tasks_expire_agent_metadata() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("metadata"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - - assert!( - server.handle_internal_event_with_forwarding(AppEvent::HookStateReported { - pane_id, - source: "custom:pi".into(), - agent_label: "pi".into(), - state: crate::detect::AgentState::Working, - message: None, - seq: None, - session_ref: None, - }) - ); - assert!( - server.handle_internal_event_with_forwarding(AppEvent::HookMetadataReported { - pane_id, - source: "user:pi-display".into(), - agent_label: Some("pi".into()), - applies_to_source: Some("custom:pi".into()), - title: Some("short lived".into()), - display_agent: None, - state_labels: HashMap::new(), - clear_title: false, - clear_display_agent: false, - clear_state_labels: false, - seq: None, - // Expiry is advanced with the captured deadline below; keep the - // pre-expiry assertion independent of wall-clock scheduling. - ttl: Some(Duration::from_secs(60)), - }) - ); - - let deadline = server - .app - .agent_metadata_deadline - .expect("metadata deadline"); - let terminal_id = server.app.state.workspaces[0] - .pane_state(pane_id) - .expect("pane") - .attached_terminal_id - .clone(); - assert_eq!( - server - .app - .state - .terminals - .get(&terminal_id) - .expect("terminal") - .effective_title() - .as_deref(), - Some("short lived") - ); - - assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false)); - - assert_eq!(server.app.agent_metadata_deadline, None); - assert_eq!( - server - .app - .state - .terminals - .get(&terminal_id) - .expect("terminal") - .effective_title(), - None - ); - assert!(server - .app - .event_hub - .events_after(0) - .iter() - .any(|(_, event)| { - event.event == crate::api::schema::EventKind::PaneAgentStatusChanged - && matches!( - &event.data, - crate::api::schema::EventData::PaneAgentStatusChanged { - title, - .. - } if title.is_none() - ) - })); - } - - #[test] - fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() { - let mut server = test_headless_server(); - let now = Instant::now(); - server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1)); - - assert!(!server.handle_scheduled_tasks_headless(now, false)); - assert_eq!(server.app.next_agent_manifest_update_check, None); - } - - #[tokio::test] - async fn headless_scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("restored"); - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); - server.app.state.view.pane_infos = workspace.tabs[0] - .layout - .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.ensure_test_terminals(); - server.clients.insert( - 1, - ClientConnection::new( - (100, 30), - crate::kitty_graphics::HostCellSize::default(), - server.app.state.host_terminal_theme, - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.effective_size = (100, 30); - server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 220, - g: 220, - b: 220, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 20, - g: 20, - b: 20, - }), - ..Default::default() - }; - server - .app - .state - .terminals - .get_mut(&terminal_id) - .expect("test terminal should exist") - .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], - dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), - }); - server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); - - assert!(!server.handle_scheduled_tasks_headless(Instant::now(), true)); - assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); - assert!(server - .app - .state - .terminals - .get(&terminal_id) - .expect("test terminal should still exist") - .pending_agent_resume_plan - .is_some()); - assert!(server.app.pending_agent_resume_deadline.is_none()); - } - - #[cfg(unix)] - #[tokio::test] - async fn headless_scheduled_tasks_start_pending_agent_resume_without_foreground_client() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("restored"); - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.ensure_test_terminals(); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .expect("test terminal should exist") - .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], - dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), - }); - - server.render_and_stream(); - assert_ne!(server.app.state.view.terminal_area, Rect::default()); - - let now = Instant::now(); - assert!(!server.handle_scheduled_tasks_headless(now, false)); - assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); - let deadline = server - .app - .pending_agent_resume_deadline - .expect("clientless resume should wait briefly for a host theme"); - - assert!(server.handle_scheduled_tasks_headless(deadline, false)); - assert!(server.app.terminal_runtimes.get(&terminal_id).is_some()); - assert!(server - .app - .state - .terminals - .get(&terminal_id) - .expect("test terminal should still exist") - .pending_agent_resume_plan - .is_none()); - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn headless_pre_input_resize_does_not_start_pending_agent_resume() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("restored"); - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); - server.app.state.view.pane_infos = workspace.tabs[0] - .layout - .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.ensure_test_terminals(); - server.clients.insert( - 1, - ClientConnection::new( - (100, 30), - crate::kitty_graphics::HostCellSize::default(), - server.app.state.host_terminal_theme, - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.effective_size = (100, 30); - server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 220, - g: 220, - b: 220, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 20, - g: 20, - b: 20, - }), - ..Default::default() - }; - server - .app - .state - .terminals - .get_mut(&terminal_id) - .expect("test terminal should exist") - .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], - dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), - }); - server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); - - server.resize_shared_runtime_to_effective_size_before_input(); - - assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); - assert!(server - .app - .state - .terminals - .get(&terminal_id) - .expect("test terminal should still exist") - .pending_agent_resume_plan - .is_some()); - assert!(server.app.pending_agent_resume_deadline.is_none()); - } - - #[test] - fn virtual_render_produces_nonempty_buffer() { - let mut state = AppState::test_new(); - let area = Rect::new(0, 0, 80, 24); - let (buffer, _cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - assert_eq!(buffer.area.width, 80); - assert_eq!(buffer.area.height, 24); - } - - #[test] - fn virtual_render_without_frame_cursor_keeps_cursor_hidden() { - let mut state = AppState::test_new(); - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - - assert_eq!(cursor, None); - } - - #[tokio::test] - async fn virtual_render_preserves_explicit_frame_cursor_position() { - let mut state = AppState::test_new(); - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - let pane = state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("focused pane info"); - - assert_eq!( - cursor, - Some(CursorState { - x: pane.inner_rect.x + 4, - y: pane.inner_rect.y, - visible: true, - shape: cursor.as_ref().map(|c| c.shape).unwrap_or(0), - }) - ); - } - - #[tokio::test] - async fn virtual_render_preserves_hidden_focused_pane_cursor_position() { - let mut state = AppState::test_new(); - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - let pane = state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("focused pane info"); - - assert_eq!( - cursor, - Some(CursorState { - x: pane.inner_rect.x + 4, - y: pane.inner_rect.y, - visible: false, - shape: cursor.as_ref().map(|c| c.shape).unwrap_or(0), - }) - ); - } - - #[tokio::test] - async fn virtual_render_hides_focused_pane_cursor_during_synchronized_output() { - let mut state = AppState::test_new(); - state.reveal_hidden_cursor_for_cjk_ime = true; - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"); - ws.insert_test_runtime(pane_id, runtime); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let _ = crate::server::render_stream::render_virtual(&mut state, area, true); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let runtime = state - .runtime_for_pane(&terminal_runtimes, pane_id) - .expect("pane runtime after initial render"); - runtime.test_process_pty_bytes(b"\x1b[?2026h\x1b[2;3H"); - assert!(runtime.synchronized_output_active()); - - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, false); - - assert_eq!( - cursor, None, - "child cursor positions are unstable while synchronized output is active" - ); - } - - #[tokio::test] - async fn virtual_render_hides_focused_pane_cursor_during_synchronized_output_resize() { - let mut state = AppState::test_new(); - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"); - ws.insert_test_runtime(pane_id, runtime); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let initial_area = Rect::new(0, 0, 80, 24); - let _ = crate::server::render_stream::render_virtual(&mut state, initial_area, true); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let runtime = state - .runtime_for_pane(&terminal_runtimes, pane_id) - .expect("pane runtime after initial render"); - runtime.test_process_pty_bytes(b"\x1b[?2026h\x1b[2;3H"); - assert!(runtime.synchronized_output_active()); - - let resized_area = Rect::new(0, 0, 100, 30); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, resized_area, true); - - assert_eq!( - cursor, None, - "pre-resize synchronized output should suppress the cursor even if resize clears the mode" - ); - } - - #[tokio::test] - async fn virtual_render_exposes_hidden_pane_cursor_when_reveal_hidden_for_cjk_ime() { - let mut state = AppState::test_new(); - state.reveal_hidden_cursor_for_cjk_ime = true; - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - let pane = state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("focused pane info"); - - assert_eq!( - cursor, - Some(CursorState { - x: pane.inner_rect.x + 4, - y: pane.inner_rect.y, - visible: true, - shape: state.cjk_ime_cursor_shape, - }) - ); - } - - #[tokio::test] - async fn virtual_render_keeps_cursor_hidden_when_scrolled_back_even_with_reveal_hidden_for_cjk_ime( - ) { - let mut state = AppState::test_new(); - state.reveal_hidden_cursor_for_cjk_ime = true; - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let mut bytes = Vec::new(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let runtime = - crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); - ws.insert_test_runtime(pane_id, runtime); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let _ = crate::server::render_stream::render_virtual(&mut state, area, true); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let runtime = state - .runtime_for_pane(&terminal_runtimes, pane_id) - .expect("pane runtime after initial render"); - runtime.scroll_up(6); - assert!(crate::ui::pane_is_scrolled_back(runtime)); - - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - - assert!( - cursor.as_ref().is_none_or(|cursor| !cursor.visible), - "scrolled-back focused pane should keep the cursor hidden even when reveal_hidden_cursor_for_cjk_ime is true; got {cursor:?}", - ); - } - - #[tokio::test] - async fn virtual_render_fallback_cursor_when_viewport_none_and_reveal_hidden_for_cjk_ime() { - let mut state = AppState::test_new(); - state.reveal_hidden_cursor_for_cjk_ime = true; - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - // Feed only ?25l with no prior cursor movement — exercises the fallback - // path for TUIs whose viewport has no cursor position. - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"\x1b[?25l"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - let pane = state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("focused pane info"); - - assert_eq!( - cursor, - Some(CursorState { - x: pane.inner_rect.x, - y: pane.inner_rect.y, - visible: true, - shape: state.cjk_ime_cursor_shape, - }), - "fallback should anchor at pane top-left with the configured shape", - ); - } - - #[tokio::test] - async fn virtual_render_skips_reveal_when_focused_pane_has_no_detected_agent() { - let mut state = AppState::test_new(); - state.reveal_hidden_cursor_for_cjk_ime = true; - // Filter only Claude, but the test pane has no detected agent, so the - // reveal must not apply. - state.cjk_ime_agent_filter_configured = true; - state.cjk_ime_agents = vec![crate::detect::Agent::Claude]; - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - - assert!( - cursor.as_ref().is_none_or(|cursor| !cursor.visible), - "agent filter should suppress reveal when the focused pane's detected agent is not on the list; got {cursor:?}", - ); - } - - #[tokio::test] - async fn virtual_render_skips_reveal_when_agent_filter_has_no_valid_entries() { - let mut state = AppState::test_new(); - state.reveal_hidden_cursor_for_cjk_ime = true; - state.cjk_ime_agent_filter_configured = true; - state.cjk_ime_agents = Vec::new(); - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - - assert!( - cursor.as_ref().is_none_or(|cursor| !cursor.visible), - "agent filter with no valid entries should suppress reveal; got {cursor:?}", - ); - } - - #[tokio::test] - async fn virtual_render_omits_focused_pane_cursor_while_mobile_switcher_open() { - let mut state = AppState::test_new(); - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Navigate; - - let area = Rect::new(0, 0, 44, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - - assert_eq!(cursor, None); - } - - #[tokio::test] - async fn virtual_render_hides_focused_pane_cursor_while_scrolled_back() { - let mut state = AppState::test_new(); - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let mut bytes = Vec::new(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let runtime = - crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); - ws.insert_test_runtime(pane_id, runtime); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let _ = crate::server::render_stream::render_virtual(&mut state, area, true); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let runtime = state - .runtime_for_pane(&terminal_runtimes, pane_id) - .expect("pane runtime after initial render"); - runtime.scroll_up(6); - assert!(crate::ui::pane_is_scrolled_back(runtime)); - - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - - assert!( - cursor.as_ref().is_none_or(|cursor| !cursor.visible), - "cursor: {cursor:?}" - ); - } - - #[test] - fn latest_active_client_drives_shared_size_theme_and_fallback() { - let mut server = test_headless_server(); - - server.clients.insert( - 1, - ClientConnection::new( - (160, 45), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 0xaa, - g: 0xbb, - b: 0xcc, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 0x11, - g: 0x22, - b: 0x33, - }), - ..Default::default() - }, - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 0x10, - g: 0x20, - b: 0x30, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 0xdd, - g: 0xee, - b: 0xff, - }), - ..Default::default() - }, - None, - 2, - RenderEncoding::SemanticFrame, - None, - ), - ); - - assert!(server.promote_client_to_foreground(1)); - assert_eq!(server.foreground_client_id, Some(1)); - assert_eq!(server.effective_size, (160, 45)); - assert_eq!( - server.app.state.host_terminal_theme, - server.clients[&1].host_terminal_theme - ); - - assert!(server.promote_client_to_foreground(2)); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!(server.effective_size, (80, 24)); - assert_eq!( - server.app.state.host_terminal_theme, - server.clients[&2].host_terminal_theme - ); - - assert!(server.remove_client(2)); - assert_eq!(server.foreground_client_id, Some(1)); - assert_eq!(server.effective_size, (160, 45)); - assert_eq!( - server.app.state.host_terminal_theme, - server.clients[&1].host_terminal_theme - ); - } - - #[test] - fn foreground_client_without_host_theme_clears_previous_host_theme() { - let mut server = test_headless_server(); - let known_theme = crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 0x10, - g: 0x20, - b: 0x30, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 0x40, - g: 0x50, - b: 0x60, - }), - ..Default::default() - }; - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - known_theme, - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - None, - ), - ); - - assert!(server.promote_client_to_foreground(1)); - assert_eq!(server.app.state.host_terminal_theme, known_theme); - - assert!(server.promote_client_to_foreground(2)); - assert_eq!( - server.app.state.host_terminal_theme, - crate::terminal_theme::TerminalTheme::default() - ); - } - - #[test] - fn foreground_client_appearance_controls_auto_theme() { - let mut server = test_headless_server(); - server.app.state.theme_runtime.auto_switch = true; - server.app.state.theme_runtime.dark_name = "catppuccin".to_string(); - server.app.state.theme_runtime.light_name = "catppuccin-latte".to_string(); - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme { - foreground: None, - background: Some(crate::terminal_theme::RgbColor { r: 0, g: 0, b: 0 }), - ..Default::default() - }, - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme { - foreground: None, - background: Some(crate::terminal_theme::RgbColor { - r: 255, - g: 255, - b: 255, - }), - ..Default::default() - }, - None, - 2, - RenderEncoding::SemanticFrame, - None, - ), - ); - - assert!(server.promote_client_to_foreground(1)); - assert_eq!(server.app.state.theme_name, "catppuccin"); - - assert!(server.promote_client_to_foreground(2)); - assert_eq!(server.app.state.theme_name, "catppuccin-latte"); - } - - #[test] - fn color_scheme_change_event_is_inert_on_server() { - let mut server = test_headless_server(); - let initial_theme = crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 0x10, - g: 0x20, - b: 0x30, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 0x40, - g: 0x50, - b: 0x60, - }), - ..Default::default() - }; - server.app.state.host_terminal_theme = initial_theme; - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - initial_theme, - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - - let changed = server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: crate::raw_input::GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec(), - }); - - assert!(!changed); - assert_eq!(server.foreground_client_id, None); - assert_eq!(server.clients[&1].host_terminal_theme, initial_theme); - assert_eq!(server.app.state.host_terminal_theme, initial_theme); - } - - #[test] - fn focus_lost_updates_client_without_promoting_foreground() { - let mut server = test_headless_server(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 2, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - let changed = server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[O".to_vec(), - }); - - assert!(!changed); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!(server.clients[&1].outer_terminal_focus, Some(false)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - } - - #[test] - fn focus_gained_promotes_client_to_foreground() { - let mut server = test_headless_server(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 2, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - let changed = server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - }); - - assert!(changed); - assert_eq!(server.foreground_client_id, Some(1)); - assert_eq!(server.clients[&1].outer_terminal_focus, Some(true)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - } - - #[tokio::test] - async fn foreground_focus_gained_reaches_pane_with_focus_reporting() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - - server.clients.insert(1, test_app_client(Some(false), 1)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - assert_eq!( - input_rx.try_recv().expect("forwarded focus gained report"), - Bytes::from_static(b"\x1b[I") - ); - - assert!(!server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[O".to_vec(), - })); - assert_eq!( - input_rx.try_recv().expect("forwarded focus lost report"), - Bytes::from_static(b"\x1b[O") - ); - } - - #[tokio::test] - async fn outer_focus_events_do_not_reach_pane_without_focus_reporting() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b""); - server.clients.insert(1, test_app_client(Some(false), 1)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - assert!(matches!( - input_rx.try_recv(), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) - )); - } - - #[tokio::test] - async fn background_focus_batch_only_forwards_events_after_promotion() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.clients.insert(2, test_app_client(Some(false), 2)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 2, - data: b"\x1b[O\x1b[I".to_vec(), - })); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - assert_eq!( - input_rx - .try_recv() - .expect("focus gained after client promotion"), - Bytes::from_static(b"\x1b[I") - ); - assert!(matches!( - input_rx.try_recv(), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) - )); - } - - #[tokio::test] - async fn background_client_focus_loss_releases_its_owned_keys() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[>15u"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.clients.insert(2, test_app_client(Some(true), 2)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('j'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - assert!(!server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![crate::protocol::ClientInputEvent::FocusLost], - })); - assert_eq!( - input_rx.try_recv().expect("forwarded press"), - Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - input_rx - .try_recv() - .expect("synthetic release from background client"), - Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(server.app.input_leases.is_empty()); - } - - #[tokio::test] - async fn structured_outer_focus_events_reach_reporting_pane() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![ - crate::protocol::ClientInputEvent::FocusGained, - crate::protocol::ClientInputEvent::FocusLost, - ], - })); - assert_eq!( - input_rx.try_recv().expect("structured focus gained report"), - Bytes::from_static(b"\x1b[I") - ); - assert_eq!( - input_rx.try_recv().expect("structured focus lost report"), - Bytes::from_static(b"\x1b[O") - ); - } - - #[tokio::test] - async fn background_key_makes_later_focus_lost_eligible() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.clients.insert(2, test_app_client(Some(true), 2)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 2, - events: vec![ - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Release, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }, - crate::protocol::ClientInputEvent::FocusLost, - ], - })); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!( - input_rx.try_recv().expect("focus lost after promotion"), - Bytes::from_static(b"\x1b[O") - ); - } - - #[tokio::test] - async fn structured_non_app_focus_is_ignored_without_suppressing_keys() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - - let mut attached = test_app_client(Some(false), 2); - attached.mode = ClientConnectionMode::TerminalAttach { - terminal_id: "attached".to_owned(), - }; - server.clients.insert(2, attached); - - let mut pending = test_app_client(Some(false), 3); - pending.pending_terminal_attach = true; - server.clients.insert(3, pending); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - for client_id in [2, 3] { - assert!(!server.handle_server_event(ServerEvent::ClientInputEvents { - client_id, - events: vec![crate::protocol::ClientInputEvent::FocusGained], - })); - assert_eq!(server.foreground_client_id, Some(1)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - assert_eq!(server.clients[&client_id].outer_terminal_focus, Some(false)); - } - - assert!(matches!( - input_rx.try_recv(), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) - )); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 3, - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Release, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - assert_eq!(server.foreground_client_id, Some(3)); - } - - #[test] - fn terminal_attach_resize_preserves_known_cell_size_when_pixels_are_omitted() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id, _pane_id| { - let mut client = ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ); - client.mode = ClientConnectionMode::TerminalAttach { - terminal_id: terminal_id.clone(), - }; - server.clients.insert(1, client); - - assert!(server.handle_server_event(ServerEvent::ClientResize { - client_id: 1, - cols: 100, - rows: 30, - cell_width_px: 0, - cell_height_px: 0, - })); - - assert_eq!( - server - .runtime_for_terminal_id_string(&terminal_id) - .unwrap() - .pixel_size(), - Some((1_000, 600)) - ); - assert_eq!( - server.clients[&1].cell_size, - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - } - ); - }); - } - - #[tokio::test] - async fn passive_mouse_motion_forwards_without_requesting_render() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1003h\x1b[?1006h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - let baseline = FrameData { - cells: Vec::new(), - width: 0, - height: 0, - cursor: None, - hyperlinks: Vec::new(), - graphics: Vec::new(), - }; - let client = server.clients.get_mut(&1).unwrap(); - let prepared = client - .render_state - .prepare_frame(baseline.clone()) - .expect("new semantic baseline"); - client.render_state.commit_sent_frame(prepared); - let pane = server.app.state.view.pane_infos[0].clone(); - let column = pane.inner_rect.x + 2; - let row = pane.inner_rect.y + 3; - let input = format!("\x1b[<35;{};{}M", column + 1, row + 1).into_bytes(); - - assert!(!server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: input, - })); - assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), - Bytes::from_static(b"\x1b[<35;3;4M") - ); - assert_eq!( - server.clients[&1].render_state.last_frame(), - Some(&baseline) - ); - } - - #[test] - fn background_mouse_motion_promotes_once_then_becomes_render_neutral() { - let mut server = test_headless_server(); - server.app.state.mode = crate::app::Mode::Terminal; - server.clients.insert(1, test_app_client(Some(true), 1)); - server.clients.insert(2, test_app_client(Some(true), 2)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - let motion = || ServerEvent::ClientInputEvents { - client_id: 2, - events: vec![crate::protocol::ClientInputEvent::Mouse { - kind: crate::protocol::ClientMouseKind::Moved, - column: 10, - row: 5, - modifiers: 0, - }], - }; - - assert!(server.handle_server_event(motion())); - assert_eq!(server.foreground_client_id, Some(2)); - assert!(!server.handle_server_event(motion())); - } - - #[test] - fn mouse_motion_in_hover_modes_requires_render() { - let events = [crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: MouseEventKind::Moved, - column: 10, - row: 5, - modifiers: KeyModifiers::empty(), - }, - )]; - - assert!(events_are_render_neutral_mouse_motion( - &events, - crate::app::Mode::Terminal - )); - for mode in [ - crate::app::Mode::GlobalMenu, - crate::app::Mode::ContextMenu, - crate::app::Mode::Navigator, - ] { - assert!(!events_are_render_neutral_mouse_motion(&events, mode)); - } - } - - fn install_focused_test_runtime( - server: &mut HeadlessServer, - terminal_bytes: &[u8], - ) -> tokio::sync::mpsc::Receiver { - let mut workspace = crate::workspace::Workspace::test_new("focus-reporting"); - let pane_id = workspace.tabs[0].root_pane; - let (runtime, input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - terminal_bytes, - 4, - ); - workspace.insert_test_runtime(pane_id, runtime); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - input_rx - } - - fn test_app_client(outer_terminal_focus: Option, last_activity: u64) -> ClientConnection { - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - outer_terminal_focus, - last_activity, - RenderEncoding::SemanticFrame, - None, - ) - } - - #[test] - fn foreground_client_focus_event_updates_app_focus_state() { - let mut server = test_headless_server(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - let changed = server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[O".to_vec(), - }); - - assert!(!changed); - assert_eq!(server.clients[&1].outer_terminal_focus, Some(false)); - assert_eq!(server.app.state.outer_terminal_focus, Some(false)); - } - - #[test] - fn app_client_lone_escape_closes_navigate_mode() { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Navigate; - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b".to_vec(), - })); - - assert_eq!(server.app.state.mode, crate::app::Mode::Terminal); - } - - #[test] - fn semantic_client_input_events_route_through_app_input() { - let mut server = test_headless_server(); - server.app.state.mode = crate::app::Mode::Onboarding; - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Enter, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - - assert_eq!(server.app.state.mode, crate::app::Mode::Settings); - assert_eq!( - server.app.state.settings.section, - crate::app::state::SettingsSection::Integrations - ); - } - - #[test] - fn semantic_client_escape_closes_keybind_help() { - let mut server = test_headless_server(); - server.app.state.mode = crate::app::Mode::KeybindHelp; - server.clients.insert( - 1, - ClientConnection::new( - (100, 30), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Esc, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - - assert_eq!(server.app.state.mode, crate::app::Mode::Navigate); - } - - #[test] - fn semantic_client_down_scrolls_keybind_help() { - let mut server = test_headless_server(); - server.app.state.mode = crate::app::Mode::KeybindHelp; - server.clients.insert( - 1, - ClientConnection::new( - (100, 30), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - assert!(server.app.state.keybind_help_max_scroll() > 0); - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Down, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - - assert_eq!(server.app.state.mode, crate::app::Mode::KeybindHelp); - assert_eq!(server.app.state.keybind_help.scroll, 1); - } - - #[tokio::test] - async fn split_default_background_response_updates_theme_without_forwarding_tail() { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - crate::terminal::TerminalRuntime::test_with_channel_capacity(80, 24, 1); - workspace.tabs[0].runtimes.insert(focused, runtime); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - let _ = server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b]".to_vec(), - }); - assert!(rx.try_recv().is_err()); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"11;#123456\x07".to_vec(), - })); - - assert!(rx.try_recv().is_err()); - assert_eq!( - server.clients[&1].host_terminal_theme.background, - Some(crate::terminal_theme::RgbColor { - r: 0x12, - g: 0x34, - b: 0x56, - }) - ); - assert_eq!( - server.app.state.host_terminal_theme.background, - Some(crate::terminal_theme::RgbColor { - r: 0x12, - g: 0x34, - b: 0x56, - }) - ); - } - - #[tokio::test] - async fn render_and_stream_uses_each_client_terminal_size() { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let active_pane = workspace.tabs[0].root_pane; - let background_tab = workspace.test_add_tab(Some("background")); - let background_pane = workspace.tabs[background_tab].root_pane; - workspace.tabs[0].runtimes.insert( - active_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"active"), - ); - workspace.tabs[background_tab].runtimes.insert( - background_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"background"), - ); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - let (desktop_tx, _desktop_control_rx, desktop_rx) = test_client_writer(); - let (mobile_tx, _mobile_control_rx, mobile_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(desktop_tx), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (44, 20), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(mobile_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - server.render_and_stream(); - - let desktop_frame = read_server_frame(desktop_rx.recv().expect("desktop frame")); - let mobile_frame = read_server_frame(mobile_rx.recv().expect("mobile frame")); - - assert_eq!((desktop_frame.width, desktop_frame.height), (120, 40)); - assert_eq!((mobile_frame.width, mobile_frame.height), (44, 20)); - let mobile_text = frame_text(&mobile_frame); - let mut mobile_rows = mobile_text.lines(); - let mobile_header = mobile_rows.by_ref().take(2).collect::(); - let mobile_surface = mobile_rows.collect::(); - assert!(mobile_header.contains("test"), "header: {mobile_header:?}"); - assert!( - mobile_surface.contains("active"), - "surface: {mobile_surface:?}" - ); - assert!(!mobile_surface.contains("background")); - - let foreground_terminal_area = Rect::new(26, 1, 94, 39); - let expected_pane_size = ( - foreground_terminal_area.height, - foreground_terminal_area.width.saturating_sub(1), - ); - assert_eq!( - server.app.state.view.layout, - crate::app::state::ViewLayout::Desktop - ); - assert_eq!(server.app.state.view.mobile_header_rect, Rect::default()); - assert_eq!( - server.app.state.view.terminal_area, - foreground_terminal_area - ); - assert_eq!( - server.app.state.workspaces[0].tabs[0].runtimes[&active_pane].current_size(), - expected_pane_size - ); - assert_eq!( - server.app.state.workspaces[0].tabs[background_tab].runtimes[&background_pane] - .current_size(), - expected_pane_size - ); - } - - #[tokio::test] - async fn resize_shared_runtime_resizes_background_tabs() { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let background_tab = workspace.test_add_tab(Some("background")); - let active_pane = workspace.tabs[0].root_pane; - let background_pane = workspace.tabs[background_tab].root_pane; - workspace.tabs[0].runtimes.insert( - active_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), - ); - workspace.tabs[background_tab].runtimes.insert( - background_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), - ); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - let terminal_area = server.app.state.view.terminal_area; - let expected = (terminal_area.height, terminal_area.width.saturating_sub(1)); - assert_eq!( - server - .app - .state - .runtime_for_pane(&server.app.terminal_runtimes, active_pane) - .unwrap() - .current_size(), - expected - ); - assert_eq!( - server - .app - .state - .runtime_for_pane(&server.app.terminal_runtimes, background_pane) - .unwrap() - .current_size(), - expected - ); - } - - #[test] - fn terminal_attach_disconnect_restores_app_pane_size() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("test"); - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); - let terminal_id_string = terminal_id.to_string(); - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - server.app.terminal_runtimes.insert( - terminal_id.clone(), - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), - ); - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - let expected_app_size = server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .current_size(); - assert_ne!(expected_app_size, (24, 80)); - - let (writer, _control_rx, _render_rx) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 2, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::TerminalAnsi, - keybindings: None, - direct_attach_requested: true, - direct_graphics: false, - writer, - })); - assert!( - server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 2, - terminal_id: terminal_id_string.clone(), - takeover: false, - }) - ); - assert_eq!(server.foreground_client_id, Some(1)); - assert!(server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - assert_eq!( - server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .current_size(), - (24, 80) - ); - - assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 2 })); - - assert!(!server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - assert_eq!( - server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .current_size(), - expected_app_size - ); - drop(server); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - #[test] - fn render_and_stream_sends_terminal_frame_for_terminal_ansi_client() { - let mut server = test_headless_server(); - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - server.render_and_stream(); - - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("terminal frame"), - ) { - ServerMessage::Terminal(frame) => { - assert_eq!(frame.seq, 1); - assert_eq!((frame.width, frame.height), (80, 24)); - assert!(frame.full); - assert!(!frame.bytes.is_empty()); - } - other => panic!("expected terminal frame, got {other:?}"), - } - assert_eq!( - server - .clients - .get(&1) - .unwrap() - .render_state - .terminal_seq() - .unwrap(), - 1 - ); - } - - #[test] - fn render_and_stream_sends_large_terminal_frame_for_terminal_ansi_client() { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (278, 85), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - server.render_and_stream(); - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial terminal frame"), - ) { - ServerMessage::Terminal(frame) => { - assert_eq!(frame.seq, 1); - assert_eq!((frame.width, frame.height), (278, 85)); - assert!(frame.full); - } - other => panic!("expected terminal frame, got {other:?}"), - } - - assert!(server.handle_server_event(ServerEvent::ClientResize { - client_id: 1, - cols: 710, - rows: 202, - cell_width_px: 0, - cell_height_px: 0, - })); - server.render_and_stream(); - - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("large terminal frame"), - ) { - ServerMessage::Terminal(frame) => { - assert_eq!(frame.seq, 2); - assert_eq!((frame.width, frame.height), (710, 202)); - assert!(frame.full); - assert!(!frame.bytes.is_empty()); - } - other => panic!("expected terminal frame, got {other:?}"), - } - - server.app.state.mode = crate::app::Mode::Navigate; - server.render_and_stream(); - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("follow-up terminal frame"), - ) { - ServerMessage::Terminal(frame) => assert_eq!(frame.seq, 3), - other => panic!("expected terminal frame, got {other:?}"), - } - } - - #[test] - fn terminal_ansi_input_does_not_reset_blit_baseline() { - let mut server = test_headless_server(); - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial terminal frame"); - assert_eq!( - server - .clients - .get(&1) - .unwrap() - .render_state - .terminal_seq() - .unwrap(), - 1 - ); - - assert!(!server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: Vec::new(), - })); - server.render_and_stream(); - - assert_eq!( - server - .clients - .get(&1) - .unwrap() - .render_state - .terminal_seq() - .unwrap(), - 1 - ); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - } - - #[test] - fn outer_focus_gained_repaints_terminal_ansi_without_clearing() { - let mut server = test_headless_server(); - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial terminal frame"); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - server.render_and_stream(); - - match read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()) { - ServerMessage::Terminal(frame) => { - assert_eq!(frame.seq, 2); - assert!(frame.full); - assert!(!frame.bytes.windows(4).any(|bytes| bytes == b"\x1b[2J")); - } - other => panic!("expected terminal frame, got {other:?}"), - } - } - - #[tokio::test] - async fn outer_focus_gained_client_render_pending_survives_semantic_render_queue_full() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial semantic frame"); - - let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) - .expect("serialize dummy message"); - server.clients[&1] - .writer - .as_ref() - .unwrap() - .test_fill_render(queued); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - - server.render_and_stream(); - - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - assert!(matches!( - read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), - ServerMessage::ReloadSoundConfig - )); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - assert!(!server.render_retained_pty_update_and_stream()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - - assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 1 })); - server.render_and_stream(); - - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::None - ); - assert!(matches!( - read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), - ServerMessage::Frame(_) - )); - } - - #[test] - fn outer_focus_gained_does_not_force_terminal_ansi_full_redraw_when_disabled() { - let mut server = test_headless_server(); - server.app.state.redraw_on_focus_gained = false; - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial terminal frame"); - - server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - }); - server.render_and_stream(); - - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - assert_eq!(server.clients[&1].outer_terminal_focus, Some(true)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - assert_eq!( - server - .clients - .get(&1) - .unwrap() - .render_state - .terminal_seq() - .unwrap(), - 1 - ); - } - - #[test] - fn outer_focus_gained_does_not_mark_semantic_render_pending_when_disabled() { - let mut server = test_headless_server(); - server.app.state.redraw_on_focus_gained = false; - let (client_tx, _client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::None - ); - assert!(!server.app.full_redraw_pending); - assert_eq!(server.clients[&1].outer_terminal_focus, Some(true)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - } - - #[test] - fn full_render_queue_does_not_advance_terminal_ansi_baseline() { - let mut server = test_headless_server(); - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) - .expect("serialize dummy message"); - client_tx.test_fill_render(queued); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - server.render_and_stream(); - - assert_eq!( - server - .clients - .get(&1) - .unwrap() - .render_state - .terminal_seq() - .unwrap(), - 0 - ); - assert!(matches!( - read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), - ServerMessage::ReloadSoundConfig - )); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - } - - #[test] - fn writer_drained_retries_pending_terminal_ansi_render() { - let mut server = test_headless_server(); - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) - .expect("serialize dummy message"); - client_tx.test_fill_render(queued); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - server.render_and_stream(); - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - assert!(matches!( - read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), - ServerMessage::ReloadSoundConfig - )); - - assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 1 })); - server.render_and_stream(); - - match read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()) { - ServerMessage::Terminal(frame) => assert_eq!(frame.seq, 1), - other => panic!("expected terminal frame, got {other:?}"), - } - assert_eq!( - server - .clients - .get(&1) - .unwrap() - .render_state - .terminal_seq() - .unwrap(), - 1 - ); - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::None - ); - } - - #[test] - fn render_and_stream_skips_identical_frame_sends() { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - server.render_and_stream(); - let first = client_rx.recv_timeout(Duration::from_millis(100)); - assert!(first.is_ok(), "expected first frame to be sent"); - - server.render_and_stream(); - assert!( - client_rx.recv_timeout(Duration::from_millis(50)).is_err(), - "identical frame should not be sent twice" - ); - } - - #[test] - fn visible_source_wakes_pending_hidden_work() { - let (server, background_pane) = hidden_pty_visibility_test_server(&[(120, 40)]); - let visible_pane = server.app.state.workspaces[0].tabs[0].root_pane; - server.sync_immediate_pty_sources(); - - assert!(server.app.render_dirty.request_pty(background_pane)); - assert!(!server.has_pending_presentation_work(false, false)); - assert!(server.app.render_dirty.request_pty(visible_pane)); - assert!(server.has_pending_presentation_work(false, false)); - } - - #[test] - fn inactive_tab_pty_source_is_hidden_until_tab_focus() { - let (server, background_pane) = hidden_pty_visibility_test_server(&[]); - let sources = HashSet::from([background_pane]); - assert!(!server.pty_sources_visible_to_any_render_target(&sources)); - - let (mut server, background_pane) = - hidden_pty_visibility_test_server(&[(120, 40), (44, 20)]); - let sources = HashSet::from([background_pane]); - assert!(!server.pty_sources_visible_to_any_render_target(&sources)); - - server.app.state.workspaces[0].switch_tab(1); - assert!(server.pty_sources_visible_to_any_render_target(&sources)); - } - - #[tokio::test] - async fn hidden_pty_output_appears_after_switching_to_its_tab() { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let background_tab = workspace.test_add_tab(Some("background")); - let background_pane = workspace.tabs[background_tab].root_pane; - workspace.insert_test_runtime( - background_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"before"), - ); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - server.render_and_stream(); - let _initial_frame = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, background_pane) - .expect("background runtime"); - runtime.test_process_pty_bytes(b"\rhidden-update"); - assert!(server.app.render_dirty.request_pty(background_pane)); - let request = server.app.render_dirty.take(); - let pty = if server.pty_sources_visible_to_any_render_target(&request.pty_sources) { - PtyRenderState::Visible - } else { - PtyRenderState::Hidden - }; - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: false, - needs_graphics_render: false, - pty, - }), - RetainedRenderPlan::HiddenPty - ); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - - server.app.state.workspaces[0].switch_tab(background_tab); - server.render_and_stream(); - let visible_frame = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("frame after tab switch"), - ); - assert!(frame_text(&visible_frame).contains("hidden-update")); - } - - #[test] - fn direct_terminal_observer_keeps_hidden_pty_source_renderable_with_app_client() { - let (mut server, background_pane) = hidden_pty_visibility_test_server(&[(120, 40)]); - assert!(!server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); - - let terminal_id = server.app.state.workspaces[0] - .terminal_id(background_pane) - .expect("background terminal id") - .to_string(); - let (client_tx, _client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new_with_mode( - ClientConnectionMode::TerminalObserve { terminal_id }, - None, - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - false, - Some(client_tx), - ), - ); - - assert!(server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); - - let hidden_pane = server.app.state.workspaces[0].tabs[0].root_pane; - server.sync_immediate_pty_sources(); - assert!(server.app.render_dirty.request_pty(background_pane)); - assert!(server.has_pending_presentation_work(false, false)); - assert!(server.app.render_dirty.request_pty(hidden_pane)); - } - - #[tokio::test] - async fn retained_pty_update_streams_dirty_row_from_last_frame() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.render_and_stream(); - let first = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"), - ); - assert!(first.cells.iter().any(|cell| cell.symbol == "a")); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - assert!(server.render_retained_pty_update_and_stream()); - let patched = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("retained frame"), - ); - assert!(patched.cells.iter().any(|cell| cell.symbol == "Z")); - assert_eq!((patched.width, patched.height), (80, 24)); - } - - #[tokio::test] - async fn retained_pty_update_declines_while_popup_is_visible() { - let (mut server, client_rx, _) = retained_test_server(b"tiled"); - let popup_runtime = - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"popup-aaaa"); - let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime); - - server.render_and_stream(); - let initial = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial popup frame"), - ); - assert!(frame_text(&initial).contains("popup-aaaa")); - server - .app - .terminal_runtimes - .get(&terminal_id) - .unwrap() - .test_process_pty_bytes(b"\rZ"); - - assert!(!server.render_retained_pty_update_and_stream()); - server.render_and_stream(); - let updated = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("full popup fallback frame"), - ); - assert!(frame_text(&updated).contains("Zopup-aaaa")); - } - - #[tokio::test] - async fn popup_forces_host_mouse_capture_for_headless_client() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.app.state.mouse_capture = false; - let popup_runtime = - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"popup"); - server.app.install_test_popup_runtime(popup_runtime); - - server.stream_host_mouse_capture_mode(); - - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("mouse capture message") - ), - ServerMessage::MouseCapture { enabled: true, .. } - )); - } - - #[tokio::test] - async fn command_mode_updates_headless_client_keyboard_flags() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - - server.app.state.mode = crate::app::Mode::Prefix; - server.stream_host_keyboard_enhancement_flags(); - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("command-mode keyboard enhancement message") - ), - ServerMessage::KittyKeyboardReportAll { enabled: true } - )); - - server.app.state.mode = crate::app::Mode::Terminal; - server.stream_host_keyboard_enhancement_flags(); - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("IME-compatible keyboard enhancement message") - ), - ServerMessage::KittyKeyboardReportAll { enabled: false } - )); - } - - #[tokio::test] - async fn focused_report_all_pane_updates_headless_client_keyboard_flags() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - let popup_runtime = - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"\x1b[>15u"); - server.app.install_test_popup_runtime(popup_runtime); - - server.stream_host_keyboard_enhancement_flags(); - - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("keyboard enhancement message") - ), - ServerMessage::KittyKeyboardReportAll { enabled: true } - )); - - assert!(server.app.close_popup_pane()); - server.app.state.mode = crate::app::Mode::Terminal; - server.stream_host_keyboard_enhancement_flags(); - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("IME-compatible keyboard enhancement message") - ), - ServerMessage::KittyKeyboardReportAll { enabled: false } - )); - } - - #[tokio::test] - async fn virtual_render_uses_popup_cursor() { - let (mut server, _client_rx, _) = retained_test_server(b"\x1b[2;2H"); - let popup_runtime = - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"\x1b[4;5H"); - let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime); - - let (_, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut server.app.state, - &server.app.terminal_runtimes, - ratatui::layout::Rect::new(0, 0, 80, 24), - true, - crate::kitty_graphics::HostCellSize::default(), - ); - let (_, inner) = - crate::ui::popup_pane_rects(&server.app.state, server.app.state.view.terminal_area) - .unwrap(); - let expected = server - .app - .terminal_runtimes - .get(&terminal_id) - .unwrap() - .cursor_state(inner, true) - .unwrap(); - - assert_eq!( - cursor, - Some(crate::protocol::CursorState { - x: expected.x, - y: expected.y, - visible: expected.visible, - shape: expected.shape, - }) - ); - } - - #[tokio::test] - async fn virtual_render_does_not_resize_directly_attached_popup() { - let (mut server, _client_rx, _) = retained_test_server(b"tiled"); - let popup_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(50, 13, b""); - let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime); - server - .app - .state - .direct_attach_resize_locks - .insert(terminal_id.clone()); - - let _ = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut server.app.state, - &server.app.terminal_runtimes, - ratatui::layout::Rect::new(0, 0, 80, 24), - true, - crate::kitty_graphics::HostCellSize::default(), - ); - - assert_eq!( - server - .app - .terminal_runtimes - .get(&terminal_id) - .unwrap() - .current_size(), - (13, 50) - ); - } - - #[tokio::test] - async fn retained_pty_update_declines_while_toast_is_visible() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.app.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "pi needs attention".to_owned(), - context: "background · 2".to_owned(), - position: None, - target: None, - }); - server.render_and_stream(); - let initial = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"), - ); - assert!( - frame_text(&initial).contains("pi needs attention"), - "expected initial full frame to include toast text" - ); - - let toast_row = server.app.state.view.toast_hit_area.y; - let inner_rect = server.app.state.view.pane_infos[0].inner_rect; - let pane_row = toast_row - .checked_sub(inner_rect.y) - .expect("toast should overlap the pane") - + 1; - assert!(pane_row <= inner_rect.height); - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(format!("\x1b[{pane_row};1Hzzzz").as_bytes()); - - assert!(!server.render_retained_pty_update_and_stream()); - assert!( - client_rx.recv_timeout(Duration::from_millis(50)).is_err(), - "retained path should not stream a frame that can overwrite toast cells" - ); - } - - #[tokio::test] - async fn retained_pty_update_declines_while_copy_feedback_is_visible() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.app.state.copy_feedback = Some(crate::app::state::CopyFeedback { - message: "copied to clipboard".to_owned(), - }); - server.render_and_stream(); - let initial = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"), - ); - let initial_text = frame_text(&initial); - assert!( - initial_text.contains("copied to clipboard"), - "expected initial full frame to include copy feedback" - ); - - let feedback_row = initial_text - .lines() - .position(|line| line.contains("copied to clipboard")) - .expect("copy feedback row") as u16; - let inner_rect = server.app.state.view.pane_infos[0].inner_rect; - let pane_row = feedback_row - .checked_sub(inner_rect.y) - .expect("copy feedback should overlap the pane") - + 1; - assert!(pane_row <= inner_rect.height); - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(format!("\x1b[{pane_row};1Hzzzz").as_bytes()); - - assert!(!server.render_retained_pty_update_and_stream()); - assert!( - client_rx.recv_timeout(Duration::from_millis(50)).is_err(), - "retained path should not stream a frame that can overwrite copy feedback cells" - ); - } - - #[tokio::test] - async fn retained_pty_update_matches_full_render_frame() { - let initial = b"\x1b[6 qleft \xe4\xb8\xad"; - let update = b"\r\x1b[44mZ\x1b[0m"; - let (mut retained_server, retained_rx, retained_pane_id) = retained_test_server(initial); - let (mut full_server, full_rx, full_pane_id) = retained_test_server(initial); - - retained_server.render_and_stream(); - let _ = retained_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial retained baseline"); - full_server.render_and_stream(); - let _ = full_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial full baseline"); - - retained_server - .app - .state - .runtime_for_pane_in_workspace( - &retained_server.app.terminal_runtimes, - 0, - retained_pane_id, - ) - .expect("retained runtime") - .test_process_pty_bytes(update); - full_server - .app - .state - .runtime_for_pane_in_workspace(&full_server.app.terminal_runtimes, 0, full_pane_id) - .expect("full runtime") - .test_process_pty_bytes(update); - - assert!(retained_server.render_retained_pty_update_and_stream()); - full_server.render_and_stream(); - - let retained_frame = read_server_frame( - retained_rx - .recv_timeout(Duration::from_millis(100)) - .expect("retained frame"), - ); - let full_frame = read_server_frame( - full_rx - .recv_timeout(Duration::from_millis(100)) - .expect("full frame"), - ); - assert_frame_data_eq(&retained_frame, &full_frame); - } - - #[tokio::test] - async fn retained_pty_update_streams_cursor_only_change() { - let initial = b"abcd"; - let update = b"\x1b[D"; - let (mut retained_server, retained_rx, retained_pane_id) = retained_test_server(initial); - let (mut full_server, full_rx, full_pane_id) = retained_test_server(initial); - - retained_server.render_and_stream(); - let _ = retained_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial retained baseline"); - full_server.render_and_stream(); - let _ = full_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial full baseline"); - - retained_server - .app - .state - .runtime_for_pane_in_workspace( - &retained_server.app.terminal_runtimes, - 0, - retained_pane_id, - ) - .expect("retained runtime") - .test_process_pty_bytes(update); - full_server - .app - .state - .runtime_for_pane_in_workspace(&full_server.app.terminal_runtimes, 0, full_pane_id) - .expect("full runtime") - .test_process_pty_bytes(update); - - assert!(retained_server.render_retained_pty_update_and_stream()); - full_server.render_and_stream(); - - let retained_frame = read_server_frame( - retained_rx - .recv_timeout(Duration::from_millis(100)) - .expect("retained cursor frame"), - ); - let full_frame = read_server_frame( - full_rx - .recv_timeout(Duration::from_millis(100)) - .expect("full cursor frame"), - ); - assert_frame_data_eq(&retained_frame, &full_frame); - } - - #[tokio::test] - async fn retained_pty_update_declines_unsafe_mode_without_consuming_dirty_rows() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - server.app.state.mode = crate::app::Mode::Navigate; - assert!(!server.render_retained_pty_update_and_stream()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - - server.app.state.mode = crate::app::Mode::Terminal; - assert!(server.render_retained_pty_update_and_stream()); - let patched = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("retained frame after safe mode"), - ); - assert!(patched.cells.iter().any(|cell| cell.symbol == "Z")); - } - - #[tokio::test] - async fn headless_full_render_clears_full_redraw_pending_for_future_retained_updates() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.app.full_redraw_pending = true; - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("full redraw frame"); - assert!(!server.app.full_redraw_pending); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - assert!(server.render_retained_pty_update_and_stream()); - } - - #[tokio::test] - async fn retained_pty_update_declines_when_patch_would_stale_hyperlinks() { - let (mut server, client_rx, pane_id) = retained_test_server(b"link"); - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"); - let inner_rect = server.app.state.view.pane_infos[0].inner_rect; - let client = server.clients.get_mut(&1).unwrap(); - let mut frame = client.render_state.last_frame().unwrap().clone(); - frame.hyperlinks = vec!["https://example.com".to_owned()]; - let hyperlink_idx = - usize::from(inner_rect.y) * usize::from(frame.width) + usize::from(inner_rect.x); - frame.cells[hyperlink_idx].hyperlink = Some(0); - let prepared = client - .render_state - .prepare_frame(frame) - .expect("hyperlink frame differs"); - client.render_state.commit_sent_frame(prepared); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rplain"); - - assert!(!server.render_retained_pty_update_and_stream()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - - server.render_and_stream(); - let full = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("full frame after hyperlink overwrite"), - ); - assert!( - full.cells.iter().all(|cell| cell.hyperlink.is_none()), - "full render should clear overwritten hyperlink cells" - ); - } - - #[tokio::test] - async fn retained_pty_update_allows_dirty_row_that_creates_plain_url() { - let (mut server, client_rx, pane_id) = retained_test_server(b"plain"); - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rhttps://example.com/new"); - - assert!(server.render_retained_pty_update_and_stream()); - let patched = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("retained frame after plain URL"), - ); - assert!( - patched.hyperlinks.is_empty(), - "retained render should not synthesize plain URL hyperlink metadata" - ); - } - - #[tokio::test] - async fn retained_pty_update_allows_kitty_enabled_empty_graphics_cache() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.app.state.kitty_graphics_enabled = true; - server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - assert!(server.render_retained_pty_update_and_stream()); - let retained = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("retained frame with kitty enabled"), - ); - assert!(retained.cells.iter().any(|cell| cell.symbol == "Z")); - } - - #[tokio::test] - async fn retained_pty_update_declines_when_graphics_cache_has_content() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.app.state.kitty_graphics_enabled = true; - let client = server.clients.get_mut(&1).unwrap(); - client.cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"); - server - .clients - .get_mut(&1) - .unwrap() - .graphics_cache - .test_mark_non_empty(); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - assert!(!server.render_retained_pty_update_and_stream()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - } - - #[tokio::test] - async fn full_redraw_pending_survives_full_render_queue_full() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) - .expect("serialize dummy message"); - server.clients[&1] - .writer - .as_ref() - .unwrap() - .test_fill_render(queued); - server.app.full_redraw_pending = true; - - server.render_and_stream(); - - assert!(server.app.full_redraw_pending); - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - assert!(matches!( - read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), - ServerMessage::ReloadSoundConfig - )); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - assert!(!server.render_retained_pty_update_and_stream()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - } - - #[test] - fn client_config_reload_request_refreshes_attached_clients() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.app.state.request_client_config_reload = true; - - server.drain_client_config_reload_request(); - - match read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("client config reload message"), - ) { - ServerMessage::ReloadSoundConfig => {} - other => panic!("expected ReloadSoundConfig, got {other:?}"), - } - assert!(!server.app.state.request_client_config_reload); - } - - #[test] - fn terminal_bell_targets_foreground_client_only() { - let mut server = test_headless_server(); - let (background_tx, background_control_rx, _background_rx) = test_client_writer(); - let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(background_tx), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(foreground_tx), - ), - ); - server.foreground_client_id = Some(2); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { - pane_id: crate::layout::PaneId::from_raw(1), - count: 3, - }); - - assert!(!changed); - match read_server_message( - foreground_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("foreground terminal bell message"), - ) { - ServerMessage::TerminalBell { count } => assert_eq!(count, 3), - other => panic!("expected terminal bell message, got {other:?}"), - } - assert!( - background_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "background client should not receive terminal bells" - ); - - server.foreground_client_id = None; - server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { - pane_id: crate::layout::PaneId::from_raw(1), - count: 1, - }); - assert!( - foreground_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "bells without a foreground client must not be retained" - ); - } - - #[test] - fn clipboard_write_targets_foreground_client_only() { - let mut server = test_headless_server(); - let (background_tx, background_control_rx, _background_rx) = test_client_writer(); - let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(background_tx), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(foreground_tx), - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { - content: b"test".to_vec(), - }); - - assert!(changed); - assert_eq!( - server - .app - .state - .copy_feedback - .as_ref() - .map(|feedback| feedback.message.as_str()), - Some("copied to clipboard") - ); - match read_server_message( - foreground_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("foreground clipboard message"), - ) { - ServerMessage::Clipboard { data } => assert_eq!(data, "dGVzdA=="), - other => panic!("expected clipboard message, got {other:?}"), - } - assert!( - background_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "background client should not receive clipboard writes" - ); - } - - #[test] - fn clipboard_write_without_foreground_client_does_not_show_feedback() { - let mut server = test_headless_server(); - server.foreground_client_id = None; - - let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { - content: b"test".to_vec(), - }); - - assert!(changed); - assert!( - server.app.state.copy_feedback.is_none(), - "clipboard feedback should only show when a foreground client can receive the write" - ); - } - - #[test] - fn clipboard_write_failed_foreground_send_does_not_show_feedback() { - let mut server = test_headless_server(); - let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); - drop(foreground_control_rx); - foreground_tx.test_close(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(foreground_tx), - ), - ); - server.foreground_client_id = Some(1); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { - content: b"test".to_vec(), - }); - - assert!(changed); - assert!( - server.app.state.copy_feedback.is_none(), - "clipboard feedback should only show after the foreground client receives the write" - ); - assert!( - !server.clients.contains_key(&1), - "failed targeted send should remove the broken foreground client" - ); - } - - #[test] - fn prefix_input_source_targets_foreground_client_only() { - let mut server = test_headless_server(); - let (background_tx, background_control_rx, _background_rx) = test_client_writer(); - let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(background_tx), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(foreground_tx), - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - // Drain any setup messages (e.g. mouse-capture sync) before exercising the event. - while foreground_control_rx - .recv_timeout(Duration::from_millis(20)) - .is_ok() - {} - - let changed = server - .handle_internal_event_with_forwarding(AppEvent::PrefixInputSource { active: true }); - - assert!(changed); - match read_server_message( - foreground_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("foreground prefix input-source message"), - ) { - ServerMessage::PrefixInputSource { active } => assert!(active), - other => panic!("expected prefix input-source message, got {other:?}"), - } - assert!( - background_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "background client should not receive prefix input-source changes" - ); - } - - #[test] - fn semantic_notifications_broadcast_only_to_client_shells() { - let mut server = test_headless_server(); - let (shell_one_tx, shell_one_control, _shell_one_frames) = test_client_writer(); - let (shell_two_tx, shell_two_control, _shell_two_frames) = test_client_writer(); - let (app_tx, app_control, _app_frames) = test_client_writer(); - for (client_id, writer) in [(1, shell_one_tx), (2, shell_two_tx)] { - server.clients.insert( - client_id, - ClientConnection::new_with_mode( - ClientConnectionMode::ClientShell, - None, - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - client_id, - RenderEncoding::SemanticFrame, - false, - Some(writer), - ), - ); - } - server.clients.insert( - 3, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 3, - RenderEncoding::SemanticFrame, - Some(app_tx), - ), - ); - let event = protocol::SemanticNotification { - kind: protocol::SemanticNotificationKind::Custom, - title: "hello".into(), - body: None, - sound: None, - agent: None, - workspace_id: None, - tab_id: None, - pane_id: None, - position: None, - }; - assert!(server.send_to_client_shells(ServerMessage::SemanticNotification(event.clone()))); - for receiver in [shell_one_control, shell_two_control] { - assert_eq!( - read_server_message( - receiver - .recv_timeout(Duration::from_millis(100)) - .expect("semantic notification") - ), - ServerMessage::SemanticNotification(event.clone()) - ); - } - assert!(app_control.recv_timeout(Duration::from_millis(50)).is_err()); - } - - #[test] - fn notification_show_uses_client_shell_policy_independent_of_server_delivery() { - let mut server = test_headless_server(); - server.app.state.toast_config.delivery = config::ToastDelivery::Off; - let (shell_tx, shell_control, _shell_frames) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new_with_mode( - ClientConnectionMode::ClientShell, - None, - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - false, - Some(shell_tx), - ), - ); - let response = server.handle_notification_show_api( - "notify-shell".into(), - api::schema::NotificationShowParams { - title: "plugin title".into(), - body: Some("plugin body".into()), - position: Some(crate::config::ToastHerdrPosition::TopLeft), - sound: api::schema::NotificationShowSound::Done, - }, - ); - let response: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert!(matches!( - response.result, - api::schema::ResponseResult::NotificationShow { shown: true, .. } - )); - assert_eq!( - read_server_message( - shell_control - .recv_timeout(Duration::from_millis(100)) - .expect("semantic plugin notification") - ), - ServerMessage::SemanticNotification(protocol::SemanticNotification { - kind: protocol::SemanticNotificationKind::Custom, - title: "plugin title".into(), - body: Some("plugin body".into()), - sound: Some(protocol::SemanticNotificationSound::Done), - agent: None, - workspace_id: None, - tab_id: None, - pane_id: None, - position: Some(crate::config::ToastHerdrPosition::TopLeft), - }) - ); - } - - #[test] - fn notification_show_preserves_foreground_app_with_background_shell() { - let mut server = test_headless_server(); - server.app.state.toast_config.delivery = config::ToastDelivery::System; - let (shell_tx, shell_control, _shell_frames) = test_client_writer(); - let (app_tx, app_control, _app_frames) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new_with_mode( - ClientConnectionMode::ClientShell, - None, - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - false, - Some(shell_tx), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(app_tx), - ), - ); - server.foreground_client_id = Some(2); - let response = server.handle_notification_show_api( - "mixed-notify".into(), - api::schema::NotificationShowParams { - title: "mixed title".into(), - body: Some("mixed body".into()), - position: None, - sound: api::schema::NotificationShowSound::None, - }, - ); - let response: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert!(matches!( - response.result, - api::schema::ResponseResult::NotificationShow { shown: true, .. } - )); - assert!(matches!( - read_server_message( - shell_control - .recv_timeout(Duration::from_millis(100)) - .expect("shell semantic notification") - ), - ServerMessage::SemanticNotification(_) - )); - assert_eq!( - read_server_message( - app_control - .recv_timeout(Duration::from_millis(100)) - .expect("legacy app notification") - ), - ServerMessage::Notify { - kind: protocol::NotifyKind::SystemToast, - message: "mixed title".into(), - body: Some("mixed body".into()), - } - ); - } - - #[test] - fn client_local_notifications_target_foreground_client_only() { - let mut server = test_headless_server(); - let (background_tx, background_control_rx, _background_rx) = test_client_writer(); - let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(background_tx), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(foreground_tx), - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - assert!(server.send_to_foreground_client(ServerMessage::Notify { - kind: protocol::NotifyKind::Toast, - message: "pi finished".to_string(), - body: Some("workspace 1".to_string()), - })); - - match read_server_message( - foreground_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("foreground toast message"), - ) { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::Toast); - assert_eq!(message, "pi finished"); - assert_eq!(body.as_deref(), Some("workspace 1")); - } - other => panic!("expected toast notify, got {other:?}"), - } - assert!( - background_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "background client should not receive client-local notifications" - ); - } - - #[test] - fn oversized_paste_rejection_notifies_only_the_sending_client() { - let mut server = test_headless_server(); - let (sender_writer, sender_control_rx, _sender_render_rx) = test_client_writer(); - let (foreground_writer, foreground_control_rx, _foreground_render_rx) = - test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(sender_writer), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(foreground_writer), - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - assert!( - !server.handle_server_event(ServerEvent::ClientPasteRejected { - client_id: 1, - size: 5_000_012, - max: 1_048_576, - }) - ); - - match read_server_message( - sender_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("sending client rejection notification"), - ) { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::Toast); - assert_eq!(message, "Paste rejected"); - assert_eq!( - body.as_deref(), - Some("Input message is 5000012 bytes; Herdr's limit is 1048576 bytes") - ); - } - other => panic!("expected paste rejection notification, got {other:?}"), - } - let (shell_writer, shell_control_rx, _shell_render_rx) = test_client_writer(); - server.clients.insert( - 3, - ClientConnection::new_with_mode( - ClientConnectionMode::ClientShell, - None, - (100, 30), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 3, - RenderEncoding::SemanticFrame, - false, - Some(shell_writer), - ), - ); - assert!( - !server.handle_server_event(ServerEvent::ClientPasteRejected { - client_id: 3, - size: 7_000_000, - max: 1_048_576, - }) - ); - match read_server_message( - shell_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("client shell rejection error"), - ) { - ServerMessage::ClientShellError { message } => assert_eq!( - message, - "Paste rejected: Input message is 7000000 bytes; Herdr's limit is 1048576 bytes" - ), - other => panic!("expected client shell paste error, got {other:?}"), - } - assert!( - foreground_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "foreground client must not receive another client's rejection" - ); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!(server.clients.len(), 3); - assert!(server.app.state.toast.is_none()); - } - - #[test] - fn herdr_toast_delivery_keeps_toast_in_frame_without_client_notify() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - - let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { - version: "9.9.9".to_string(), - install_command: "herdr update".into(), - }); - - assert!(changed); - assert!(server.app.state.toast.is_some()); - assert!( - client_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "herdr delivery should render in-frame instead of forwarding a client-local notification" - ); - } - - #[test] - fn system_toast_delivery_forwards_system_notify_kind() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - - let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { - version: "9.9.9".to_string(), - install_command: "herdr update".into(), - }); - - assert!(changed); - match read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("system toast message"), - ) { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::SystemToast); - assert_eq!(message, "v9.9.9 available"); - assert_eq!( - body.as_deref(), - Some("detach, run `herdr update`, then follow its restart guidance") - ); - } - other => panic!("expected system toast notify, got {other:?}"), - } - } - - #[test] - fn notification_show_api_forwards_system_notification_to_foreground_client() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "build failed".into(), - body: Some("api workspace".into()), - position: Some(crate::config::ToastHerdrPosition::TopLeft), - sound: api::schema::NotificationShowSound::Request, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: true, - reason: api::schema::NotificationShowReason::Shown, - } - ); - let first = read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("api notification message"), - ); - let second = read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("api sound message"), - ); - - match first { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::SystemToast); - assert_eq!(message, "build failed"); - assert_eq!(body.as_deref(), Some("api workspace")); - } - other => panic!("expected api notification, got {other:?}"), - } - match second { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::Sound); - assert_eq!(message, "agent attention"); - assert!(body.is_none()); - } - other => panic!("expected api sound, got {other:?}"), - } - } - - #[test] - fn notification_show_api_preserves_colon_in_forwarded_title() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "build: failed".into(), - body: Some("api workspace".into()), - position: None, - sound: api::schema::NotificationShowSound::None, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: true, - reason: api::schema::NotificationShowReason::Shown, - } - ); - match read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("api notification message"), - ) { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::SystemToast); - assert_eq!(message, "build: failed"); - assert_eq!(body.as_deref(), Some("api workspace")); - } - other => panic!("expected api notification, got {other:?}"), - } - } - - #[test] - fn notification_show_api_validates_empty_title_before_disabled_delivery() { - let mut server = test_headless_server(); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::Off; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "\n\t".into(), - body: None, - position: None, - sound: api::schema::NotificationShowSound::None, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed.error.code, "invalid_params"); - assert_eq!(parsed.error.message, "notification title is empty"); - } - - #[test] - fn notification_show_api_reports_no_foreground_client() { - let mut server = test_headless_server(); - server.foreground_client_id = None; - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "build failed".into(), - body: None, - position: None, - sound: api::schema::NotificationShowSound::Request, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: false, - reason: api::schema::NotificationShowReason::NoForegroundClient, - } - ); - } - - #[test] - fn notification_show_api_herdr_toast_expires_headless() { - let mut server = test_headless_server(); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - assert!( - server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "build failed".into(), - body: None, - position: None, - sound: api::schema::NotificationShowSound::None, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }) - ); - - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: true, - reason: api::schema::NotificationShowReason::Shown, - } - ); - let deadline = server.app.toast_deadline.expect("api toast deadline"); - assert!(server.handle_scheduled_tasks_headless(deadline, false)); - assert!(server.app.state.toast.is_none()); - assert!(server.app.toast_deadline.is_none()); - } - - #[test] - fn notification_show_api_forwards_sound_for_herdr_delivery() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - assert!( - server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "build failed".into(), - body: None, - position: None, - sound: api::schema::NotificationShowSound::Done, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }) - ); - - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: true, - reason: api::schema::NotificationShowReason::Shown, - } - ); - match read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("api sound message"), - ) { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::Sound); - assert_eq!(message, "agent done"); - assert!(body.is_none()); - } - other => panic!("expected api sound, got {other:?}"), - } - } - - #[test] - fn startup_idle_does_not_forward_completion() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("active"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - server.app.state.toast_config.delay_seconds = 0; - server.app.state.sound.enabled = true; - - assert!( - server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected { - pane_id, - agent: crate::detect::Agent::Pi, - observed_at: Instant::now(), - }) - ); - - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(false), - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - while client_control_rx - .recv_timeout(Duration::from_millis(20)) - .is_ok() - {} - - assert!( - server.handle_internal_event_with_forwarding(AppEvent::StateChanged { - pane_id, - agent: Some(crate::detect::Agent::Pi), - state: crate::detect::AgentState::Idle, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: Instant::now(), - }) - ); - assert!( - client_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "startup readiness should not forward a completion notification" - ); - } - - #[test] - fn delayed_agent_notification_forwards_after_deadline() { - let mut server = test_headless_server(); - let background = crate::workspace::Workspace::test_new("background"); - let pane_id = background.tabs[0].root_pane; - let foreground = crate::workspace::Workspace::test_new("foreground"); - server.app.state.workspaces = vec![background, foreground]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(1); - server.app.state.selected = 1; - server.app.state.mode = crate::app::Mode::Terminal; - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - server.app.state.toast_config.delay_seconds = 1; - - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::StateChanged { - pane_id, - agent: Some(crate::detect::Agent::Pi), - state: crate::detect::AgentState::Blocked, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: Instant::now(), - }); - - assert!(changed); - assert!(server.app.state.toast.is_none()); - assert!( - client_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "delayed transition should not notify immediately" - ); - - let deadline = server - .app - .state - .next_pending_agent_notification_deadline() - .expect("pending notification deadline"); - assert!(server.handle_scheduled_tasks_headless(deadline, false)); - - let first = read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("delayed sound message"), - ); - let second = read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("delayed toast message"), - ); - - assert!(matches!( - first, - ServerMessage::Notify { - kind: protocol::NotifyKind::Sound, - .. - } - )); - match second { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::SystemToast); - assert_eq!(message, "pi needs attention"); - assert_eq!(body.as_deref(), Some("background · 1")); - } - other => panic!("expected delayed system toast, got {other:?}"), - } - assert!(server.app.state.pending_agent_notifications.is_empty()); - } - - #[test] - fn delayed_active_tab_unfocused_agent_notification_forwards_after_deadline() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("active"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - server.app.state.toast_config.delay_seconds = 1; - - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(false), - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!( - server.handle_internal_event_with_forwarding(AppEvent::StateChanged { - pane_id, - agent: Some(crate::detect::Agent::Pi), - state: crate::detect::AgentState::Blocked, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: Instant::now(), - }) - ); - assert!(server.app.state.toast.is_none()); - assert!( - client_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "delayed transition should not notify immediately" - ); - - let deadline = server - .app - .state - .next_pending_agent_notification_deadline() - .expect("pending notification deadline"); - assert!(server.handle_scheduled_tasks_headless(deadline, false)); - - let first = read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("delayed sound message"), - ); - let second = read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("delayed toast message"), - ); - - assert!(matches!( - first, - ServerMessage::Notify { - kind: protocol::NotifyKind::Sound, - .. - } - )); - match second { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::SystemToast); - assert_eq!(message, "pi needs attention"); - assert_eq!(body.as_deref(), Some("active · 1")); - } - other => panic!("expected delayed system toast, got {other:?}"), - } - } - - #[test] - fn stale_api_agent_report_does_not_forward_done_sound() { - let mut server = test_headless_server(); - let background = crate::workspace::Workspace::test_new("background"); - let pane_id = background.tabs[0].root_pane; - let public_pane_id = format!("{}:p1", background.id); - let foreground = crate::workspace::Workspace::test_new("foreground"); - server.app.state.workspaces = vec![background, foreground]; - server.app.state.ensure_test_terminals(); - let terminal_id = server.app.state.workspaces[0] - .pane_state(pane_id) - .unwrap() - .attached_terminal_id - .clone(); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_detected_state( - Some(crate::detect::Agent::Pi), - crate::detect::AgentState::Idle, - ); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { - source: "herdr:pi".into(), - agent: "pi".into(), - session_ref: crate::agent_resume::AgentSessionRef::path( - std::env::current_dir() - .unwrap() - .join("headless-pi-session.jsonl") - .display() - .to_string(), - ) - .unwrap(), - }); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_hook_authority( - "herdr:pi".into(), - "pi".into(), - crate::detect::AgentState::Working, - None, - Some(20), - ); - server.app.state.active = Some(1); - server.app.state.selected = 1; - server.app.state.mode = crate::app::Mode::Terminal; - - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "stale".into(), - method: api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams { - pane_id: public_pane_id, - source: "herdr:pi".into(), - agent: "pi".into(), - state: api::schema::PaneAgentState::Idle, - message: None, - seq: Some(19), - agent_session_id: None, - agent_session_path: None, - }), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok()); - assert_eq!( - server.app.state.terminals.get(&terminal_id).unwrap().state, - crate::detect::AgentState::Working - ); - assert!( - client_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "stale idle report must not forward a done sound" - ); - } - - /// Verify that no direct calls to `self.app.handle_internal_event` - /// (or its `handle_internal_event_with_prefix_sync` wrapper) exist - /// outside of `handle_internal_event_with_forwarding` in this - /// module. This ensures the forwarding bypass cannot be reintroduced. - /// - /// The search pattern looks for `handle_internal_event` calls that - /// are NOT inside the `handle_internal_event_with_forwarding` method. - #[test] - fn no_handle_internal_event_bypass_in_module() { - let source = include_str!("headless.rs"); - - // Find all lines containing handle_internal_event - let mut bypass_lines: Vec = Vec::new(); - let mut inside_forwarding_method = false; - let mut forwarding_method_brace_depth = 0u32; - - for (i, line) in source.lines().enumerate() { - let line_num = i + 1; - - // Track when we're inside handle_internal_event_with_forwarding - if line.contains("fn handle_internal_event_with_forwarding") { - inside_forwarding_method = true; - forwarding_method_brace_depth = 0; - } - - if inside_forwarding_method { - // Count braces to track when we exit the method - for ch in line.chars() { - match ch { - '{' => forwarding_method_brace_depth += 1, - '}' => { - forwarding_method_brace_depth = - forwarding_method_brace_depth.saturating_sub(1); - if forwarding_method_brace_depth == 0 { - inside_forwarding_method = false; - } - } - _ => {} - } - } - } else if (line.contains("self.app.handle_internal_event(") - || line.contains("self.app.handle_internal_event_with_prefix_sync(")) - && !line.trim().starts_with("///") - && !line.contains("contains(") - { - // Direct call to handle_internal_event outside the forwarding method - bypass_lines.push(format!("line {}: {}", line_num, line.trim())); - } - } - - assert!( - bypass_lines.is_empty(), - "Found direct calls to self.app.handle_internal_event outside \ - handle_internal_event_with_forwarding (bypass risk):\n {}", - bypass_lines.join("\n ") - ); - } -} +mod tests; diff --git a/src/server/headless/bootstrap.rs b/src/server/headless/bootstrap.rs new file mode 100644 index 00000000..638b1b2d --- /dev/null +++ b/src/server/headless/bootstrap.rs @@ -0,0 +1,218 @@ +use super::*; + +/// Run the headless server. This is the entry point called from main.rs. +pub fn run_server() -> io::Result<()> { + init_logging(); + crate::platform::raise_server_nofile_limit(); + + let args: Vec = std::env::args().collect(); + if args.get(2).map(String::as_str) == Some("--handoff-import") { + let socket_path = args + .get(3) + .map(PathBuf::from) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff socket"))?; + let token = args + .get(4) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff token"))?; + return run_handoff_import_server(&socket_path, token); + } + + let loaded_config = config::Config::load(); + let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let event_hub = api::EventHub::default(); + let should_quit = Arc::new(AtomicBool::new(false)); + + // Start the JSON API socket server. + let _api_server = match api::start_server_with_stop_control( + api_tx.clone(), + event_hub.clone(), + should_quit.clone(), + ) { + Ok(server) => server, + Err(err) if err.kind() == io::ErrorKind::AddrInUse => { + eprintln!("error: herdr server is already running"); + eprintln!("api socket: {}", api::socket_path().display()); + std::process::exit(1); + } + Err(err) => return Err(err), + }; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(io::Error::other)?; + + let result = rt.block_on(async { + // Create the App (with AppState, event channels, etc.). + let mut app = app::App::new( + &loaded_config.config, + app::AppPolicy::PRODUCTION, + config::config_diagnostic_summary(&loaded_config.diagnostics), + api_rx, + event_hub, + ); + seed_startup_workspace_if_empty(&mut app); + + // Create the headless server. + let mut server = match HeadlessServer::new( + app, + &loaded_config.diagnostics, + Some(api_tx.clone()), + Some(_api_server), + should_quit, + ) { + Ok(server) => server, + Err(err) if err.kind() == io::ErrorKind::AddrInUse => { + eprintln!("error: herdr server is already running"); + eprintln!("client socket: {}", client_socket_path().display()); + std::process::exit(1); + } + Err(err) => return Err(err), + }; + + info!( + api_socket = %api::socket_path().display(), + client_socket = %client_socket_path().display(), + "herdr server started" + ); + print_ready_message(&api::socket_path(), &client_socket_path()); + server.app.run_plugin_startup_hooks(); + + server.run().await + }); + + rt.shutdown_timeout(Duration::from_millis(100)); + crate::logging::shutdown("server"); + result +} + +fn seed_startup_workspace_if_empty(app: &mut app::App) { + let Some(cwd) = take_startup_cwd() else { + return; + }; + + if !app.state.workspaces.is_empty() { + info!( + cwd = %cwd.display(), + "restored session already has workspaces; ignoring startup cwd" + ); + return; + } + + match app.create_workspace_with_options(cwd.clone(), true) { + Ok(_) => { + info!(cwd = %cwd.display(), "created startup workspace"); + } + Err(err) => { + warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace"); + app.state.mode = app::Mode::Navigate; + } + } +} + +fn take_startup_cwd() -> Option { + let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?; + std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR); + (!cwd.is_empty()).then(|| PathBuf::from(cwd)) +} + +#[cfg(unix)] +fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> { + let loaded_config = config::Config::load(); + let mut received = crate::server::handoff::receive(socket_path, token)?; + crate::server::handoff::log_import_result(received.manifest.panes.len()); + + let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let event_hub = api::EventHub::default(); + let should_quit = Arc::new(AtomicBool::new(false)); + + let mut imports = HashMap::new(); + for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) { + let pane_id = pane.pane_id; + imports.insert( + pane_id, + crate::handoff_runtime::ImportedHandoffRuntime { + master_fd: fd, + state: pane, + }, + ); + } + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(io::Error::other)?; + + let result = rt.block_on(async { + let app = app::App::new_from_handoff( + &loaded_config.config, + config::config_diagnostic_summary(&loaded_config.diagnostics), + api_rx, + event_hub.clone(), + &received.manifest.snapshot, + &mut imports, + )?; + crate::server::handoff::report_restored(&mut received.stream)?; + if std::env::var("HERDR_TEST_HANDOFF_IMPORT_FAIL").as_deref() == Ok("after_restored") { + return Err(io::Error::other( + "test handoff import failure after restored", + )); + } + wait_for_old_public_sockets_to_close(Duration::from_secs(5))?; + + let api_server = api::start_server_with_stop_control( + api_tx.clone(), + event_hub.clone(), + should_quit.clone(), + )?; + let mut server = HeadlessServer::new( + app, + &loaded_config.diagnostics, + Some(api_tx.clone()), + Some(api_server), + should_quit, + )?; + // Carried across before any client attaches, so the first title sent is + // the override rather than the configured one it replaced. + server.api_window_title = received.manifest.api_window_title.take(); + crate::server::handoff::report_ready(&mut received.stream)?; + crate::server::handoff::wait_committed(&mut received.stream)?; + server.app.assume_handoff_ownership(); + server.app.unpause_handoff_readers(); + server.pending_handoff_repaint_nudge = true; + if let Err(err) = crate::server::handoff::report_owned(&mut received.stream) { + warn!(err = %err, "failed to report handoff ownership; continuing as owner"); + } + info!("handoff import server started"); + print_ready_message(&api::socket_path(), &client_socket_path()); + server.app.run_plugin_startup_hooks(); + server.run().await + }); + + rt.shutdown_timeout(Duration::from_millis(100)); + crate::logging::shutdown("server"); + result +} + +#[cfg(not(unix))] +fn run_handoff_import_server(_socket_path: &Path, _token: &str) -> io::Result<()> { + Err(io::Error::other("live handoff is only supported on Unix")) +} + +fn print_ready_message(api_socket: &Path, client_socket: &Path) { + eprintln!("herdr server running; you can use any herdr CLI command in another terminal."); + eprintln!("api socket: {}", api_socket.display()); + eprintln!("client socket: {}", client_socket.display()); + eprintln!( + "logs: {}", + crate::session::data_dir() + .join("herdr-server.log") + .display() + ); + eprintln!("did you mean to open the Herdr TUI? run `herdr`; you do not need `herdr server`."); +} + +/// Initialize logging for the server process. +fn init_logging() { + crate::logging::init_file_logging("herdr-server.log"); +} diff --git a/src/server/headless/lifecycle.rs b/src/server/headless/lifecycle.rs new file mode 100644 index 00000000..ab1448c3 --- /dev/null +++ b/src/server/headless/lifecycle.rs @@ -0,0 +1,399 @@ +use super::*; + +const LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT: Duration = Duration::from_secs(6); + +pub(super) fn wait_for_live_handoff_response_write( + response_write_complete: Option>, +) { + let Some(response_write_complete) = response_write_complete else { + return; + }; + + match response_write_complete.recv_timeout(LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT) { + Ok(()) => {} + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + warn!("timed out waiting for live handoff response write; old server exiting"); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + warn!("live handoff response writer disconnected; old server exiting"); + } + } +} + +impl HeadlessServer { + #[cfg(unix)] + pub(super) fn perform_live_handoff( + &mut self, + params: crate::api::schema::ServerLiveHandoffParams, + ) -> io::Result<()> { + info!("starting live handoff"); + let import_exe = params.import_exe.as_deref().map(std::path::PathBuf::from); + let socket_path = crate::server::handoff::handoff_socket_path(); + let token = format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let listener = match crate::server::handoff::bind_listener(&socket_path) { + Ok(listener) => listener, + Err(err) => { + self.handoff_in_progress = false; + return Err(err); + } + }; + + let mut pane_by_terminal = HashMap::new(); + for ws in &self.app.state.workspaces { + for tab in &ws.tabs { + for (pane_id, pane) in &tab.panes { + pane_by_terminal.insert(pane.attached_terminal_id.clone(), pane_id.raw()); + } + } + } + if pane_by_terminal.len() > crate::server::handoff::MAX_FDS_PER_HANDOFF { + let _ = std::fs::remove_file(&socket_path); + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "live handoff supports at most {} panes in one update; close panes or restart herdr normally", + crate::server::handoff::MAX_FDS_PER_HANDOFF + ), + )); + } + + self.handoff_in_progress = true; + self.disconnect_all_clients_for_handoff(); + let _ = reject_pending_client_connections(&self.client_listener); + + let mut paused_terminal_ids = Vec::new(); + for terminal_id in pane_by_terminal.keys() { + if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) { + if let Err(err) = runtime.pause_handoff_reader(Duration::from_secs(2)) { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + paused_terminal_ids.push(terminal_id.clone()); + } + } + + let snapshot = crate::persist::capture( + &self.app.state.workspaces, + &self.app.state.terminals, + &self.app.terminal_runtimes, + self.app.state.active, + self.app.state.selected, + ); + + let mut handoff_entries = Vec::new(); + for (terminal_id, runtime) in self.app.terminal_runtimes.iter() { + let Some(pane_id) = pane_by_terminal.get(terminal_id).copied() else { + continue; + }; + let mut handoff_runtime = runtime.handoff_runtime_state(pane_id); + let has_agent_session = self + .app + .state + .terminals + .get(terminal_id) + .is_some_and(|terminal| terminal.persisted_agent_session.is_some()); + if !has_agent_session { + handoff_runtime.initial_history_ansi = runtime.handoff_history_ansi(); + } + handoff_entries.push((terminal_id.clone(), handoff_runtime)); + } + + let panes = handoff_entries + .iter() + .map(|(_, runtime)| runtime.clone()) + .collect(); + let manifest = crate::server::handoff::manifest_for( + snapshot, + panes, + params.expected_protocol, + params.expected_version, + self.api_window_title.clone(), + ); + let mut import_child = match crate::server::handoff::spawn_handoff_import( + import_exe.as_deref(), + &socket_path, + &token, + ) { + Ok(child) => child, + Err(err) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + }; + let child_pid = import_child.id(); + info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server"); + + let mut fds = Vec::new(); + let duplicate_result = (|| { + for (terminal_id, _) in &handoff_entries { + let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) else { + continue; + }; + fds.push(runtime.duplicate_handoff_fd()?); + } + Ok::<(), io::Error>(()) + })(); + if let Err(err) = duplicate_result { + for fd in fds { + let _ = unsafe { libc::close(fd) }; + } + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + + let mut stream = match crate::server::handoff::accept_and_validate_on( + listener, + &socket_path, + &token, + &manifest, + ) { + Ok(stream) => stream, + Err(err) => { + for fd in fds { + let _ = unsafe { libc::close(fd) }; + } + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + }; + + let send_result = crate::server::handoff::send_fds_and_wait_restored(&mut stream, &fds); + for fd in fds { + let _ = unsafe { libc::close(fd) }; + } + if let Err(err) = send_result { + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + + if let Some(api_server) = &self.api_server { + let _ = api_server.remove_socket_file_if_owned(); + } else { + let _ = std::fs::remove_file(crate::api::socket_path()); + } + let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity); + if let Err(err) = crate::server::handoff::wait_ready(&mut stream) { + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + match self.wait_then_restore_public_sockets_after_failed_handoff() { + Ok(()) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + } + Err(restore_err) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(io::Error::other(format!( + "handoff replacement server did not become ready: {err}; old server could not restore public sockets: {restore_err}" + ))); + } + } + return Err(io::Error::other(format!( + "handoff replacement server did not become ready: {err}" + ))); + } + if let Err(err) = crate::server::handoff::report_committed(&mut stream) { + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + match self.wait_then_restore_public_sockets_after_failed_handoff() { + Ok(()) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + } + Err(restore_err) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(io::Error::other(format!( + "handoff replacement server was ready, but commit failed: {err}; old server could not restore public sockets: {restore_err}" + ))); + } + } + return Err(err); + } + + for (terminal_id, runtime) in self.app.terminal_runtimes.drain_for_handoff() { + if !pane_by_terminal.contains_key(&terminal_id) { + continue; + } + debug!(terminal = %terminal_id, "preserving pane runtime for handoff"); + runtime.preserve_for_handoff(); + } + crate::server::handoff::wait_owned_ack(&mut stream); + + Ok(()) + } + + pub(super) fn finish_live_handoff_shutdown(&mut self) { + self.shutting_down = true; + self.app.state.should_quit = true; + self.app.policy.persist_session = false; + info!("live handoff completed; old server exiting"); + } + + #[cfg(not(unix))] + pub(super) fn perform_live_handoff( + &mut self, + _params: crate::api::schema::ServerLiveHandoffParams, + ) -> io::Result<()> { + Err(io::Error::other("live handoff is only supported on Unix")) + } + + #[cfg(unix)] + fn restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { + let api_tx = self + .api_tx + .clone() + .ok_or_else(|| io::Error::other("cannot restore api socket without api sender"))?; + let api_server = api::start_server_with_stop_control( + api_tx, + self.app.event_hub.clone(), + self.should_quit.clone(), + )?; + + let client_path = client_socket_path(); + prepare_socket_path(&client_path)?; + let listener = bind_local_listener(&client_path)?; + restrict_socket_permissions(&client_path)?; + let client_socket_identity = socket_file_identity(&client_path)?; + listener.set_nonblocking(ListenerNonblockingMode::Accept)?; + + self.api_server = Some(api_server); + self.client_listener = listener; + self.client_socket_path = client_path; + self.client_socket_identity = client_socket_identity; + Ok(()) + } + + #[cfg(unix)] + fn wait_then_restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { + let timeout = crate::server::handoff::COMMIT_TIMEOUT + Duration::from_secs(2); + wait_for_old_public_sockets_to_close(timeout)?; + self.restore_public_sockets_after_failed_handoff() + } + + #[cfg(unix)] + fn rollback_handoff_before_commit( + &mut self, + socket_path: &Path, + paused_terminal_ids: &[crate::terminal::TerminalId], + ) { + for terminal_id in paused_terminal_ids { + if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) { + runtime.set_handoff_reader_paused(false); + } + } + self.handoff_in_progress = false; + let _ = std::fs::remove_file(socket_path); + } + + #[cfg(unix)] + pub(super) fn nudge_handoff_panes_on_first_client_attach(&mut self) { + if !self.pending_handoff_repaint_nudge { + return; + } + self.pending_handoff_repaint_nudge = false; + self.app + .terminal_runtimes + .nudge_child_redraw_after_handoff(); + } + + #[cfg(not(unix))] + pub(super) fn nudge_handoff_panes_on_first_client_attach(&mut self) {} + /// Initiates graceful shutdown. + pub(super) fn initiate_shutdown(&mut self) { + if self.shutting_down { + return; + } + info!("server shutdown initiated"); + self.shutting_down = true; + + // Clear client-local host graphics, then send ServerShutdown to all connected clients. + let shutdown_msg = ServerMessage::ServerShutdown { + reason: Some("server is shutting down".to_owned()), + }; + self.send_to_all_clients(shutdown_msg); + + // Give client writer threads a moment to flush the shutdown message. + // A short sleep ensures the message is written to the socket before + // we close the connections. + std::thread::sleep(Duration::from_millis(50)); + + // Signal the main loop to exit. + self.should_quit.store(true, Ordering::Release); + self.app.state.should_quit = true; + } + + /// Completes the shutdown sequence: send ServerShutdown to clients, + /// close client connections, remove socket files, and clean up. + pub(super) async fn complete_shutdown(&mut self) -> io::Result<()> { + info!("completing server shutdown"); + self.reject_late_client_connections().await; + + // Send ServerShutdown to all remaining clients. + if !self.clients.is_empty() { + let shutdown_msg = ServerMessage::ServerShutdown { + reason: Some("server is shutting down".to_owned()), + }; + self.send_to_all_clients(shutdown_msg); + + // Give writer threads a moment to flush before closing. + std::thread::sleep(Duration::from_millis(50)); + } + + // Reject only the requests already queued when shutdown reached cleanup. + self.reject_queued_api_requests_for_shutdown(); + + // Close all client connections. + let staged_files = self + .clients + .drain() + .flat_map(|(_, client)| client.staged_clipboard_files) + .collect::>(); + crate::server::clipboard_image::remove_files(staged_files); + + // Remove socket files. + self.cleanup_sockets()?; + + Ok(()) + } + + /// Removes socket files created by the server. + pub(super) fn cleanup_sockets(&self) -> io::Result<()> { + if let Err(err) = + remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity) + { + if err.kind() != io::ErrorKind::NotFound { + warn!( + path = %self.client_socket_path.display(), + err = %err, + "failed to remove client socket on shutdown" + ); + } + } + Ok(()) + } +} + +#[cfg(unix)] +pub(super) fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> { + let deadline = Instant::now() + timeout; + let api_socket = api::socket_path(); + let client_socket = client_socket_path(); + while Instant::now() < deadline { + let api_open = api_socket.exists() && crate::ipc::connect_local_stream(&api_socket).is_ok(); + let client_open = + client_socket.exists() && crate::ipc::connect_local_stream(&client_socket).is_ok(); + if !api_open && !client_open { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(io::Error::new( + io::ErrorKind::TimedOut, + "old server sockets did not close before handoff import bind", + )) +} diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs new file mode 100644 index 00000000..3587b5cf --- /dev/null +++ b/src/server/headless/notifications.rs @@ -0,0 +1,674 @@ +use super::*; + +impl HeadlessServer { + fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState { + self.app + .state + .workspaces + .iter() + .find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + let pane = tab.panes.get(&pane_id)?; + self.app + .state + .terminals + .get(&pane.attached_terminal_id) + .map(|terminal| terminal.state) + }) + }) + .unwrap_or(crate::detect::AgentState::Unknown) + } + + fn pane_effective_agent_label(&self, pane_id: crate::layout::PaneId) -> Option { + self.app.state.workspaces.iter().find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + let pane = tab.panes.get(&pane_id)?; + self.app + .state + .terminals + .get(&pane.attached_terminal_id) + .and_then(|terminal| terminal.effective_agent_label()) + .map(str::to_string) + }) + }) + } + + fn forward_semantic_agent_notification( + &mut self, + update: &crate::app::actions::PaneStateUpdate, + ) -> bool { + if update.suppress_completion { + return false; + } + self.forward_semantic_agent_transition( + update.ws_idx, + update.pane_id, + update.previous_state, + update.state, + update.previous_agent_label.as_deref(), + update.agent_label.as_deref(), + update.known_agent.or(update.previous_known_agent), + ) + } + + pub(super) fn forward_semantic_agent_transition( + &mut self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + previous_state: crate::detect::AgentState, + state: crate::detect::AgentState, + previous_agent_label: Option<&str>, + agent_label: Option<&str>, + known_agent: Option, + ) -> bool { + let Some(kind) = crate::app::actions::notification_toast_for_state_change_with_agent_labels( + false, + previous_state, + state, + previous_agent_label, + agent_label, + ) else { + return false; + }; + let Some(workspace) = self.app.state.workspaces.get(ws_idx) else { + return false; + }; + let Some(tab_idx) = workspace.find_tab_index_for_pane(pane_id) else { + return false; + }; + let Some(tab_number) = workspace.public_tab_number(tab_idx) else { + return false; + }; + let Some(public_pane_id) = self.app.public_pane_id(ws_idx, pane_id) else { + return false; + }; + let Some(agent_label) = agent_label.or(previous_agent_label) else { + return false; + }; + let (semantic_kind, event_text, sound) = match kind { + crate::app::state::ToastKind::NeedsAttention => ( + protocol::SemanticNotificationKind::NeedsAttention, + "needs attention", + Some(protocol::SemanticNotificationSound::Request), + ), + crate::app::state::ToastKind::Finished => ( + protocol::SemanticNotificationKind::Finished, + "finished", + Some(protocol::SemanticNotificationSound::Done), + ), + crate::app::state::ToastKind::UpdateInstalled => ( + protocol::SemanticNotificationKind::UpdateInstalled, + "updated", + None, + ), + }; + let workspace_id = workspace.id.clone(); + let tab_id = crate::workspace::public_tab_id_for_number(&workspace_id, tab_number); + let workspace_label = + workspace.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes); + let context = + crate::app::actions::notification_context(workspace, &workspace_label, ws_idx, pane_id); + let agent = known_agent + .map(crate::detect::agent_label) + .map(str::to_owned); + self.send_to_client_shells(ServerMessage::SemanticNotification( + protocol::SemanticNotification { + kind: semantic_kind, + title: format!("{agent_label} {event_text}"), + body: non_empty_body(&context), + sound, + agent, + workspace_id: Some(workspace_id), + tab_id: Some(tab_id), + pane_id: Some(public_pane_id), + position: None, + }, + )) + } + + fn forward_pane_state_update_notifications_to_clients( + &mut self, + update: &crate::app::actions::PaneStateUpdate, + ) { + if self.app.state.toast_config.delay_seconds != 0 { + return; + } + + let is_active_tab = self + .app + .state + .pane_is_in_active_tab(update.ws_idx, update.pane_id); + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); + + if !update.suppress_completion && self.app.state.sound.allows(update.known_agent) { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + update.previous_state, + update.state, + update.previous_agent_label.as_deref(), + update.agent_label.as_deref(), + ) + { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + } + + if !should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + return; + } + let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update( + suppress_active_tab_notifications, + update, + ) else { + return; + }; + let Some(ws) = self.app.state.workspaces.get(update.ws_idx) else { + return; + }; + let Some(agent_label) = update.agent_label.as_deref() else { + return; + }; + let event_text = match kind { + crate::app::state::ToastKind::NeedsAttention => "needs attention", + crate::app::state::ToastKind::Finished => "finished", + crate::app::state::ToastKind::UpdateInstalled => "updated", + }; + let workspace_label = + ws.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes); + let context = crate::app::actions::notification_context( + ws, + &workspace_label, + update.ws_idx, + update.pane_id, + ); + self.send_notify_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + format!("{agent_label} {event_text}"), + non_empty_body(&context), + ); + } + + pub(super) fn forward_agent_notification_delivery( + &mut self, + delivery: &crate::app::state::AgentNotificationDelivery, + ) { + if let Some(sound) = delivery.sound { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + + if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + if let Some(toast) = &delivery.client_notification { + self.send_notify_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + &toast.title, + non_empty_body(&toast.context), + ); + } + } + } + + pub(super) fn send_notify_to_foreground_client( + &mut self, + kind: protocol::NotifyKind, + message: impl Into, + body: Option, + ) -> bool { + self.send_to_foreground_client(ServerMessage::Notify { + kind, + message: message.into(), + body, + }) + } + + pub(super) fn send_flat_toast_to_foreground_client( + &mut self, + kind: protocol::NotifyKind, + message: impl AsRef, + ) -> bool { + let (title, body) = crate::terminal_notify::split_message(message.as_ref()); + self.send_notify_to_foreground_client(kind, title, body.map(str::to_string)) + } + + pub(super) fn handle_notification_show_api( + &mut self, + id: String, + params: api::schema::NotificationShowParams, + ) -> String { + use api::schema::NotificationShowReason; + + let Some(title) = sanitize_notification_text(¶ms.title, 80) else { + return serde_json::to_string(&api::schema::ErrorResponse { + id, + error: api::schema::ErrorBody { + code: "invalid_params".into(), + message: "notification title is empty".into(), + }, + }) + .unwrap_or_else(|_| "{}".to_string()); + }; + + let body = params + .body + .as_deref() + .and_then(|body| sanitize_notification_text(body, 240)); + let has_client_shell = self.clients.values().any(ClientConnection::is_shell_client); + if !has_client_shell { + let reason = if self.app.state.toast_config.delivery == config::ToastDelivery::Off { + NotificationShowReason::Disabled + } else { + NotificationShowReason::NoForegroundClient + }; + return notification_show_result(id, false, reason); + } + if self.app.api_notification_rate_limited(Instant::now()) { + return notification_show_result(id, false, NotificationShowReason::RateLimited); + } + let sound = match params.sound { + api::schema::NotificationShowSound::None => None, + api::schema::NotificationShowSound::Done => { + Some(protocol::SemanticNotificationSound::Done) + } + api::schema::NotificationShowSound::Request => { + Some(protocol::SemanticNotificationSound::Request) + } + }; + let shown = self.send_to_client_shells(ServerMessage::SemanticNotification( + protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::Custom, + title, + body, + sound, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: params.position, + }, + )); + if shown { + self.app.mark_api_notification_shown(Instant::now()); + } + notification_show_result( + id, + shown, + if shown { + NotificationShowReason::Shown + } else { + NotificationShowReason::NoForegroundClient + }, + ) + } + + /// Handles a single internal event with forwarding logic for clipboard, + /// sound, and toast notifications to connected clients. + /// + /// ALL internal events MUST be routed through this method to ensure + /// clipboard/notify forwarding is never bypassed. Do not call + /// `self.app.handle_internal_event()` directly for any internal event + /// in the headless server — use this method instead. + /// + /// Returns true if the event changed visual state (requiring a re-render). + pub(super) fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool { + match &ev { + AppEvent::TerminalBell { pane_id, count } => { + if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) { + debug!( + pane = pane_id.raw(), + count, "dropped terminal bell without a foreground client" + ); + } + false + } + AppEvent::ClipboardWrite { content } => { + // Clipboard writes are client-local side effects. Forward them only to + // the foreground client instead of broadcasting to every attached client. + let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice()); + self.send_to_foreground_client(ServerMessage::Clipboard { data }); + false + } + AppEvent::StateChanged { pane_id, agent, .. } => { + // Capture toast before handling. + let toast_before = self.app.state.toast.clone(); + let pane_id_val = *pane_id; + let agent_val = *agent; + + // Find the previous effective state of this pane before the event + // is processed. Notifications must follow effective state changes, + // not raw fallback reports that may be masked by hook authority. + let prev_state = self.pane_effective_state(pane_id_val); + let prev_agent_label = self.pane_effective_agent_label(pane_id_val); + + // Handle the state change (updates pane state, sets toast on AppState). + // Headless mode disables local sound playback separately from the + // sound policy so reloads can keep server-side notification policy live. + self.sync_foreground_client_state(); + let pane_updates = self.app.handle_internal_event_with_pane_updates(ev); + let suppress_completion = pane_updates + .iter() + .any(|update| update.pane_id == pane_id_val && update.suppress_completion); + for update in pane_updates + .iter() + .filter(|update| update.pane_id == pane_id_val) + { + self.forward_semantic_agent_notification(update); + } + + // Forward sound notification to clients when server-side sound policy allows it. + let is_active_tab = self + .app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + .is_some_and(|ws| { + ws.find_tab_index_for_pane(pane_id_val) + .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) + }); + + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); + + let next_state = self.pane_effective_state(pane_id_val); + let next_agent_label = self.pane_effective_agent_label(pane_id_val); + + if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 + && self.app.state.sound.allows(agent_val) + { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + next_agent_label.as_deref(), + ) + { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + } + + let toast_msg = if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 + && should_forward_toast_to_clients(self.app.state.toast_config.delivery) + { + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else { + toast_message_from_state_change( + &self.app.state, + &self.app.terminal_runtimes, + pane_id_val, + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + ) + } + } else { + None + }; + + if let Some(msg) = toast_msg { + self.send_flat_toast_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + msg, + ); + } + + true + } + AppEvent::HookStateReported { + pane_id, + agent_label, + .. + } => { + // Hook reports can be stale or no-op after sequence rejection. + // Forward only effective state changes observed after handling. + let toast_before = self.app.state.toast.clone(); + let pane_id_val = *pane_id; + let agent_val = crate::detect::parse_agent_label(agent_label); + + // Capture the previous effective state for this pane. Hook reports + // are already folded into pane.state; raw hook transitions must not + // produce a second notification path. + let prev_state = self.pane_effective_state(pane_id_val); + let prev_agent_label = self.pane_effective_agent_label(pane_id_val); + + self.sync_foreground_client_state(); + let pane_updates = self.app.handle_internal_event_with_pane_updates(ev); + let suppress_completion = pane_updates + .iter() + .any(|update| update.pane_id == pane_id_val && update.suppress_completion); + for update in pane_updates + .iter() + .filter(|update| update.pane_id == pane_id_val) + { + self.forward_semantic_agent_notification(update); + } + + // Forward sound notification based on the effective transition when + // server-side sound policy allows it. + let is_active_tab = self + .app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + .is_some_and(|ws| { + ws.find_tab_index_for_pane(pane_id_val) + .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) + }); + + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); + + let next_state = self.pane_effective_state(pane_id_val); + let next_agent_label = self.pane_effective_agent_label(pane_id_val); + + if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 + && self.app.state.sound.allows(agent_val) + { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + next_agent_label.as_deref(), + ) + { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + } + + let toast_msg = if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 + && should_forward_toast_to_clients(self.app.state.toast_config.delivery) + { + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else { + toast_message_from_state_change( + &self.app.state, + &self.app.terminal_runtimes, + pane_id_val, + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + ) + } + } else { + None + }; + + if let Some(msg) = toast_msg { + self.send_flat_toast_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + msg, + ); + } + + true + } + AppEvent::UpdateReady { + version, + install_command, + } => { + let toast_before = self.app.state.toast.clone(); + let version = version.clone(); + let install_command = install_command.clone(); + + self.app.handle_internal_event(ev); + self.send_to_client_shells(ServerMessage::SemanticNotification( + protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::UpdateInstalled, + title: format!("Herdr v{version} available"), + body: Some(crate::update::update_install_instruction(&install_command)), + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }, + )); + + let toast_msg = + if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else { + Some(format!( + "v{version} available: {}", + crate::update::update_install_instruction(&install_command) + )) + } + } else { + None + }; + + if let Some(msg) = toast_msg { + self.send_flat_toast_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + msg, + ); + } + + true + } + AppEvent::PaneDied { pane_id } => { + let pane_id_val = *pane_id; + let terminal_id = self.app.state.workspaces.iter().find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + tab.panes + .get(pane_id) + .map(|pane| pane.attached_terminal_id.to_string()) + }) + }); + if let Some(update) = self + .app + .state + .publish_pane_process_exit_if_agent(pane_id_val) + { + self.app.emit_pane_state_update(&update); + self.forward_semantic_agent_notification(&update); + self.forward_pane_state_update_notifications_to_clients(&update); + } + + self.app.handle_internal_event(ev); + + if self.app.find_pane(pane_id_val).is_none() { + if let Some(terminal_id) = terminal_id { + self.shutdown_terminal_stream_clients( + &terminal_id, + format!("terminal {terminal_id} exited"), + ); + } + } + + true + } + _ => self.app.handle_internal_event_with_render_impact(ev), + } + } + + /// Drains internal events, forwarding clipboard, sound, and toast + /// notifications to connected clients instead of processing them locally. + /// + /// The server has no host terminal or audio subsystem, so we: + /// - Forward `ClipboardWrite` as `ServerMessage::Clipboard` to the + /// foreground client only. + /// - Detect when a sound would be played and forward as + /// `ServerMessage::Notify { kind: Sound }` to the foreground client. + /// - Detect when a toast is set on AppState and forward as + /// `ServerMessage::Notify` to the foreground client for terminal/system delivery. + pub(super) fn drain_internal_events_with_forwarding(&mut self) -> bool { + self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT) + .1 + } + + pub(super) fn drain_all_internal_events_with_forwarding(&mut self) -> bool { + let mut changed = false; + loop { + let (had_event, batch_changed) = + self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT); + changed |= batch_changed; + if !had_event || self.should_quit.load(Ordering::Acquire) { + break; + } + } + changed + } + + pub(super) fn drain_internal_events_with_forwarding_up_to( + &mut self, + limit: usize, + ) -> (bool, bool) { + let mut had_event = false; + let mut changed = false; + for _ in 0..limit { + let Ok(ev) = self.app.event_rx.try_recv() else { + break; + }; + had_event = true; + changed |= self.handle_internal_event_with_forwarding(ev); + } + (had_event, changed) + } +} diff --git a/src/server/headless/pane_graphics.rs b/src/server/headless/pane_graphics.rs index bcc33bc4..bce53b72 100644 --- a/src/server/headless/pane_graphics.rs +++ b/src/server/headless/pane_graphics.rs @@ -1,21 +1,7 @@ use super::{HeadlessServer, RenderImpact}; use crate::api; use crate::protocol::{ServerMessage, MAX_GRAPHICS_FRAME_SIZE}; -use crate::server::clients::{render_targets, ClientConnectionMode, DeferredRender}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum RetainedGraphicsOutcome { - Sent, - Deferred, - Fallback, -} - -pub(super) fn frame_pane_graphics(bytes: Vec) -> Vec { - if bytes.is_empty() { - return bytes; - } - [b"\x1b7".as_slice(), &bytes, b"\x1b8"].concat() -} +use crate::server::clients::ClientConnectionMode; impl HeadlessServer { pub(super) fn pane_graphics_runtime_active(&self) -> bool { @@ -87,47 +73,29 @@ impl HeadlessServer { (direct_key.clone(), direct_frame) { let prepared = self.clients.get(&client_id).and_then(|client| { - if matches!(client.mode, ClientConnectionMode::ClientShell) { - let layer = self - .app - .pane_graphics - .slots - .get(&key) - .and_then(|slot| slot.layer.as_ref())?; - let asset = crate::kitty_graphics::surface::pane_layer_asset_key( - &self.app, &key, layer, - )?; - let (image_id, control) = - crate::kitty_graphics::surface::direct_upload_control( - &self.client_shell_boot_id, - &asset, - ); - Some(( - crate::kitty_graphics::DirectFileCommand { - leading: Vec::new(), - control, - }, - Some(asset), - image_id, - )) - } else { - let image_id = self - .app - .pane_graphics - .slots - .get(&key) - .map(|slot| slot.host_image_id)?; - crate::kitty_graphics::prepare_direct_file( - &self.app.state, - &self.app.pane_graphics, - self.app.state.view.tab_surface(), - client.cell_size, - !internal_changed, - &client.graphics_cache, - &key, - ) - .map(|command| (command, None, image_id)) - } + matches!(client.mode, ClientConnectionMode::ClientShell).then_some(())?; + let layer = self + .app + .pane_graphics + .slots + .get(&key) + .and_then(|slot| slot.layer.as_ref())?; + let asset = crate::kitty_graphics::surface::pane_layer_asset_key( + &self.app, &key, layer, + )?; + let (image_id, control) = + crate::kitty_graphics::surface::direct_upload_control( + &self.client_shell_boot_id, + &asset, + ); + Some(( + crate::kitty_graphics::DirectFileCommand { + leading: Vec::new(), + control, + }, + Some(asset), + image_id, + )) }); let Some((command, surface_asset, image_id)) = prepared else { if self.install_inline_fallback(&key) { @@ -349,11 +317,11 @@ impl HeadlessServer { .iter() .any(|workspace| workspace.pane_state(key.0).is_some()); if !pane_is_live { - self.retire_direct_gate(&key); + self.retire_direct_gate_with_client_notice(&key); return false; } if success { - let (gate, host_image_id) = { + let gate = { let slot = self .app .pane_graphics @@ -366,25 +334,8 @@ impl HeadlessServer { if let Some(layer) = slot.layer.as_mut() { layer.mark_resident(client_id); } - ( - slot.direct_gate.take().expect("matched gate"), - slot.host_image_id, - ) + slot.direct_gate.take().expect("matched gate") }; - if let (Some(client), Some(layer)) = ( - self.clients - .get_mut(&client_id) - .filter(|client| matches!(client.mode, ClientConnectionMode::App)), - self.app - .pane_graphics - .slots - .get(&key) - .and_then(|slot| slot.layer.as_ref()), - ) { - client - .graphics_cache - .trust_pane_layer(&key, host_image_id, layer); - } if gate.respond_to.send(gate.success_response).is_err() { self.retire_direct_gate(&key); return true; @@ -401,20 +352,6 @@ impl HeadlessServer { self.retire_all_direct_graphics(); return true; } - if let Some(client) = self - .clients - .get_mut(&client_id) - .filter(|client| matches!(client.mode, ClientConnectionMode::App)) - { - let host_image_id = self - .app - .pane_graphics - .slots - .get(&key) - .map(|slot| slot.host_image_id) - .unwrap_or(image_id); - client.graphics_cache.forget_pane_layer(&key, host_image_id); - } let gate = self .app .pane_graphics @@ -471,8 +408,53 @@ impl HeadlessServer { !expired.is_empty() } + fn retire_direct_gate_with_client_notice(&mut self, key: &crate::app::pane_graphics::Key) { + if let Some((client_id, transfer_id, image_id)) = self + .app + .pane_graphics + .slots + .get(key) + .and_then(|slot| slot.direct_gate.as_ref()) + .map(|gate| (gate.client_id, gate.transfer_id, gate.image_id)) + { + self.send_to_client( + client_id, + ServerMessage::GraphicsTransmissionRetired { + transfer_id, + image_id, + }, + ); + } + self.retire_direct_gate(key); + } + + pub(super) fn retain_live_pane_graphics(&mut self) -> bool { + let dead_direct = self + .app + .pane_graphics + .slots + .iter() + .filter(|((pane_id, _), slot)| { + slot.direct_gate.is_some() + && !self + .app + .state + .workspaces + .iter() + .any(|workspace| workspace.pane_state(*pane_id).is_some()) + }) + .map(|(key, _)| key.clone()) + .collect::>(); + let changed = !dead_direct.is_empty(); + for key in dead_direct { + self.retire_direct_gate_with_client_notice(&key); + } + let retained = self.app.pane_graphics.retain_live_panes(&self.app.state); + changed || retained + } + pub(super) fn retire_all_direct_graphics(&mut self) { - let keys = self + let retired = self .app .pane_graphics .slots @@ -482,9 +464,26 @@ impl HeadlessServer { .as_ref() .is_some_and(crate::app::pane_graphics::Layer::terminal_only) }) - .map(|(key, _)| key.clone()) + .map(|(key, slot)| { + let notification = slot + .direct_gate + .as_ref() + .map(|gate| (gate.client_id, gate.transfer_id, gate.image_id)); + (key.clone(), notification) + }) .collect::>(); - for key in keys { + for (_, notification) in &retired { + if let Some((client_id, transfer_id, image_id)) = notification { + self.send_to_client( + *client_id, + ServerMessage::GraphicsTransmissionRetired { + transfer_id: *transfer_id, + image_id: *image_id, + }, + ); + } + } + for (key, _) in retired { self.retire_direct_gate(&key); } } @@ -510,127 +509,4 @@ impl HeadlessServer { self.retire_direct_gate(&key); } } - - pub(super) fn render_retained_graphics_update_and_stream(&mut self) -> RetainedGraphicsOutcome { - crate::render_prof::event("retained_graphics.attempt"); - if self.app.full_redraw_pending { - crate::render_prof::event("retained_graphics_fallback.full_redraw_pending"); - return RetainedGraphicsOutcome::Fallback; - } - - let render_targets = render_targets(&self.clients, self.foreground_client_id); - let mut app_view_size = None; - for (_, terminal_size, _, _, mode) in &render_targets { - if !matches!(mode, ClientConnectionMode::App) { - continue; - } - if app_view_size.is_some_and(|size| size != *terminal_size) { - crate::render_prof::event("retained_graphics_fallback.mixed_app_geometry"); - return RetainedGraphicsOutcome::Fallback; - } - app_view_size = Some(*terminal_size); - } - let mut deferred = false; - let mut prepared = Vec::new(); - - for (client_id, (cols, rows), cell_size, _is_foreground, mode) in render_targets { - if !matches!(mode, ClientConnectionMode::App) { - continue; - } - let Some(client) = self.clients.get_mut(&client_id) else { - crate::render_prof::event("retained_graphics_fallback.client_missing"); - return RetainedGraphicsOutcome::Fallback; - }; - if client.deferred_render() != DeferredRender::None { - deferred = true; - continue; - } - if client.graphics_surface_reset_pending || !cell_size.is_known() { - crate::render_prof::event("retained_graphics_fallback.client_state"); - return RetainedGraphicsOutcome::Fallback; - } - let Some(last_frame) = client.render_state.last_frame() else { - crate::render_prof::event("retained_graphics_fallback.no_last_frame"); - return RetainedGraphicsOutcome::Fallback; - }; - if last_frame.width != cols || last_frame.height != rows { - crate::render_prof::event("retained_graphics_fallback.frame_size_mismatch"); - return RetainedGraphicsOutcome::Fallback; - } - if client.writer.is_none() { - crate::render_prof::event("retained_graphics_fallback.writer_missing"); - return RetainedGraphicsOutcome::Fallback; - } - - let mut next_graphics_cache = client.graphics_cache.clone(); - let encode_started = crate::render_prof::timer(); - let encoded = crate::kitty_graphics::encode_local_pane_graphics( - &self.app.state, - &self.app.pane_graphics, - &self.app.terminal_runtimes, - self.app.state.view.tab_surface(), - cell_size, - Some(crate::kitty_graphics::HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - &mut next_graphics_cache, - ); - crate::render_prof::duration_since("retained_graphics.graphics_encode", encode_started); - prepared.push((client_id, encoded, next_graphics_cache)); - } - - let mut broken_clients = Vec::new(); - for (client_id, encoded, next_graphics_cache) in prepared { - let Some(client) = self.clients.get_mut(&client_id) else { - continue; - }; - let serialized = if encoded.bytes.is_empty() { - None - } else { - match Self::frame_server_message_with_max( - &ServerMessage::Graphics { - bytes: frame_pane_graphics(encoded.bytes), - }, - MAX_GRAPHICS_FRAME_SIZE, - ) { - Ok(serialized) => Some(serialized), - Err(_) => { - crate::render_prof::event("retained_graphics_fallback.oversized"); - return RetainedGraphicsOutcome::Fallback; - } - } - }; - let result = match (serialized, client.writer.as_ref()) { - (None, _) => Ok(()), - (Some(bytes), Some(writer)) => writer.render.try_send(bytes), - (Some(bytes), None) => Err(std::sync::mpsc::TrySendError::Disconnected(bytes)), - }; - match result { - Ok(()) => { - client.graphics_cache = next_graphics_cache; - if encoded.incomplete { - client.defer_full_render(); - deferred = true; - } else { - client.clear_deferred_render(); - } - crate::render_prof::event("retained_graphics.sent"); - } - Err(std::sync::mpsc::TrySendError::Full(_)) => { - client.defer_full_render(); - deferred = true; - } - Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { - broken_clients.push(client_id) - } - } - } - for client_id in broken_clients { - self.remove_client_and_resize_if_needed(client_id); - } - - if deferred { - RetainedGraphicsOutcome::Deferred - } else { - RetainedGraphicsOutcome::Sent - } - } } diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs new file mode 100644 index 00000000..b83df9f0 --- /dev/null +++ b/src/server/headless/render.rs @@ -0,0 +1,607 @@ +use super::*; + +impl HeadlessServer { + fn focused_pane_graphics_demand(&self) -> bool { + self.app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + .and_then(crate::workspace::Workspace::focused_pane_id) + .is_some_and(|pane_id| self.app.pane_graphics.active_for_pane(pane_id)) + } + + pub(super) fn stream_host_mouse_capture_mode(&mut self) { + let endpoint_shell_mouse = self.app.state.popup_pane.is_some() + || self + .app + .state + .focused_pane_requests_mouse_capture_from(&self.app.terminal_runtimes); + let pixel_mouse_requested = self + .clients + .values() + .any(|client| client.is_shell_client() && client.pixel_mouse); + let shell_sgr_pixels = pixel_mouse_requested + && self.focused_pane_graphics_demand() + && self + .app + .state + .active + .and_then(|ws_idx| { + self.app + .state + .workspaces + .get(ws_idx) + .and_then(crate::workspace::Workspace::focused_pane_id) + .and_then(|pane_id| { + self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + ws_idx, + pane_id, + ) + }) + }) + .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled); + let requested = self + .clients + .iter() + .filter_map(|(&client_id, client)| match &client.mode { + ClientConnectionMode::ClientShell => Some(( + client_id, + client.shell_mouse_capture || endpoint_shell_mouse, + shell_sgr_pixels && client.pixel_mouse, + )), + ClientConnectionMode::TerminalAttach { terminal_id } => { + let runtime = self.runtime_for_terminal_id_string(terminal_id); + let child_requests_mouse = runtime + .is_some_and(crate::terminal::TerminalRuntime::mouse_reporting_enabled); + let sgr_pixels = child_requests_mouse + && client.pixel_mouse + && runtime + .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled); + Some((client_id, child_requests_mouse, sgr_pixels)) + } + ClientConnectionMode::TerminalPending + | ClientConnectionMode::TerminalObserve { .. } => None, + }) + .collect::>(); + + let mut broken_clients = Vec::new(); + for (client_id, enabled, sgr_pixels) in requested { + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + if client.host_mouse_capture_active == Some(enabled) + && client.host_sgr_pixels_active == Some(sgr_pixels) + { + continue; + } + let Some(writer) = &client.writer else { + continue; + }; + let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { + enabled, + sgr_pixels, + }) { + Ok(framed) => framed, + Err(err) => { + warn!(err = %err, "failed to serialize mouse capture mode for client"); + continue; + } + }; + if writer.control.send(serialized).is_err() { + debug!( + client_id, + "client writer channel closed during mouse capture update" + ); + broken_clients.push(client_id); + continue; + } + client.host_mouse_capture_active = Some(enabled); + client.host_sgr_pixels_active = Some(sgr_pixels); + } + + for client_id in broken_clients { + self.remove_client_and_resize_if_needed(client_id); + } + } + + pub(super) fn stream_direct_terminal_keyboard_mode(&mut self) { + let shell_report_all = { + let runtime = if self.app.state.popup_pane.is_some() { + self.app.popup_runtime() + } else { + self.app.state.active.and_then(|workspace_index| { + self.app + .state + .focused_runtime_in_workspace(&self.app.terminal_runtimes, workspace_index) + }) + }; + runtime.is_some_and(|runtime| { + let protocol = runtime.keyboard_protocol(); + protocol.reports_all_keys() + || (protocol.reports_event_types() && runtime.modify_other_keys_level() > 0) + }) + }; + let serialized_shell = + Self::frame_server_message(&ServerMessage::ClientShellKeyboardReportAll { + enabled: shell_report_all, + }); + let mut broken_clients = Vec::new(); + for (&client_id, client) in &mut self.clients { + if !client.is_shell_client() + || client.host_keyboard_report_all_active == Some(shell_report_all) + { + continue; + } + let Some(writer) = &client.writer else { + continue; + }; + let Ok(serialized) = serialized_shell.as_ref() else { + warn!("failed to serialize client shell keyboard report-all mode"); + break; + }; + if writer.control.send(serialized.clone()).is_err() { + broken_clients.push(client_id); + continue; + } + client.host_keyboard_report_all_active = Some(shell_report_all); + } + + let requested = self + .clients + .iter() + .filter_map(|(&client_id, client)| { + let ClientConnectionMode::TerminalAttach { terminal_id } = &client.mode else { + return None; + }; + let (flags, modify_other_keys_level) = self + .runtime_for_terminal_id_string(terminal_id) + .map_or((0, 0), |runtime| { + let flags = match runtime.keyboard_protocol() { + crate::input::KeyboardProtocol::Legacy => 0, + crate::input::KeyboardProtocol::Kitty { flags } => flags, + }; + (flags, runtime.modify_other_keys_level()) + }); + Some((client_id, flags, modify_other_keys_level)) + }) + .collect::>(); + + for (client_id, flags, modify_other_keys_level) in requested { + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + if client.host_keyboard_protocol_active == Some((flags, modify_other_keys_level)) { + continue; + } + let Some(writer) = &client.writer else { + continue; + }; + let serialized = + match Self::frame_server_message(&ServerMessage::DirectTerminalKeyboardProtocol { + flags, + modify_other_keys_level, + }) { + Ok(framed) => framed, + Err(err) => { + warn!(err = %err, "failed to serialize direct terminal keyboard mode"); + continue; + } + }; + if writer.control.send(serialized).is_err() { + debug!( + client_id, + "client writer channel closed during direct terminal keyboard update" + ); + broken_clients.push(client_id); + continue; + } + client.host_keyboard_protocol_active = Some((flags, modify_other_keys_level)); + } + + for client_id in broken_clients { + self.remove_client_and_resize_if_needed(client_id); + } + } + + pub(super) fn has_pending_presentation_work( + &self, + needs_full_render: bool, + needs_graphics_render: bool, + ) -> bool { + needs_full_render || needs_graphics_render || self.app.render_dirty.has_immediate_work() + } + + pub(super) fn sync_immediate_pty_sources(&self) { + let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); + let mut pane_ids = if has_app_target { + self.app.state.app_surface_pane_ids() + } else { + HashSet::new() + }; + if !direct_terminal_targets.is_empty() { + for workspace in &self.app.state.workspaces { + for tab in &workspace.tabs { + pane_ids.extend(tab.panes.iter().filter_map(|(&pane_id, pane)| { + direct_terminal_targets + .contains(pane.attached_terminal_id.as_str()) + .then_some(pane_id) + })); + } + } + if let Some(popup) = &self.app.state.popup_pane { + if direct_terminal_targets.contains(popup.terminal_id.as_str()) { + pane_ids.insert(popup.pane_id); + } + } + } + self.app.render_dirty.set_immediate_pty_sources(pane_ids); + } + + fn pty_render_targets(&self) -> (bool, HashSet<&str>) { + let mut has_app_target = false; + let mut direct_terminal_targets = HashSet::new(); + for client in self + .clients + .values() + .filter(|client| client.writer.is_some()) + { + match &client.mode { + ClientConnectionMode::ClientShell => has_app_target = true, + ClientConnectionMode::TerminalAttach { terminal_id } + | ClientConnectionMode::TerminalObserve { terminal_id } => { + direct_terminal_targets.insert(terminal_id.as_str()); + } + ClientConnectionMode::TerminalPending => {} + } + } + (has_app_target, direct_terminal_targets) + } + + fn pty_source_visible_to_render_targets( + &self, + pane_id: crate::layout::PaneId, + has_app_target: bool, + direct_terminal_targets: &HashSet<&str>, + ) -> bool { + let terminal_id = self.terminal_id_for_pane(pane_id); + (has_app_target && (terminal_id.is_none() || self.app_surface_contains_pane(pane_id))) + || terminal_id.is_none_or(|source| direct_terminal_targets.contains(source.as_str())) + } + + pub(super) fn pty_sources_visible_to_any_render_target( + &self, + sources: &HashSet, + ) -> bool { + let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); + if !has_app_target && direct_terminal_targets.is_empty() { + return false; + } + + sources.iter().copied().any(|pane_id| { + self.pty_source_visible_to_render_targets( + pane_id, + has_app_target, + &direct_terminal_targets, + ) + }) + } + + fn terminal_id_for_pane( + &self, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::terminal::TerminalId> { + if let Some(popup) = self + .app + .state + .popup_pane + .as_ref() + .filter(|popup| popup.pane_id == pane_id) + { + return Some(&popup.terminal_id); + } + self.app + .find_pane(pane_id) + .map(|(_, pane)| &pane.attached_terminal_id) + } + + fn app_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool { + if self + .app + .state + .popup_pane + .as_ref() + .is_some_and(|popup| popup.pane_id == pane_id) + { + return true; + } + let Some(workspace) = self + .app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + else { + return false; + }; + let Some(tab) = workspace.active_tab() else { + return false; + }; + if !tab.panes.contains_key(&pane_id) { + return false; + } + !tab.zoomed || tab.layout.focused() == pane_id + } + + pub(super) fn render_and_stream(&mut self) { + let full_started = crate::render_prof::timer(); + let render_targets = render_targets(&self.clients, self.foreground_client_id); + + if render_targets.is_empty() { + let (cols, rows) = self.effective_size; + let area = Rect::new(0, 0, cols, rows); + let resize_panes = self.app.state.view.pane_infos.is_empty(); + if resize_panes { + crate::ui::compute_view_with_runtime_registry( + &mut self.app.state, + &self.app.terminal_runtimes, + area, + ); + } else { + crate::ui::compute_view_without_resizing_panes( + &mut self.app.state, + &self.app.terminal_runtimes, + area, + ); + } + self.app.full_redraw_pending = false; + crate::render_prof::duration_since("full_render.total", full_started); + debug!( + cols, + rows, resize_panes, "updated geometry with no attached clients" + ); + return; + } + + let shell_snapshot_template = render_targets + .iter() + .any(|(_, _, _, _, mode)| matches!(mode, ClientConnectionMode::ClientShell)) + .then(|| client_shell_snapshot(&self.app, &self.client_shell_boot_id, 0, None)); + let mut broken_clients: Vec = Vec::new(); + let mut deferred_frame = false; + for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets { + let area = Rect::new(0, 0, cols, rows); + let mut shell_projection_revision = 0; + if matches!(mode, ClientConnectionMode::ClientShell) { + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + let Some(mut candidate) = shell_snapshot_template.clone() else { + continue; + }; + candidate.config_diagnostic = if client.shell_uses_endpoint_keybindings { + self.server_config_diagnostic.clone() + } else { + self.server_config_diagnostic_without_keybindings.clone() + }; + candidate.revision = client.shell_projection_revision; + if client.shell_snapshot.as_ref() != Some(&candidate) { + client.shell_projection_revision = + client.shell_projection_revision.saturating_add(1); + candidate.revision = client.shell_projection_revision; + let message = ServerMessage::ClientShellSnapshot(Box::new(candidate.clone())); + let framed = match Self::frame_server_message(&message) { + Ok(framed) => framed, + Err(err) => { + warn!(client_id, err = %err, "failed to frame client shell replacement"); + broken_clients.push(client_id); + continue; + } + }; + let Some(writer) = client.writer.as_ref() else { + broken_clients.push(client_id); + continue; + }; + if writer.control.send(framed).is_err() { + broken_clients.push(client_id); + continue; + } + client.shell_snapshot = Some(candidate); + } + shell_projection_revision = client.shell_projection_revision; + } + let shell_graphics_delivery = self + .clients + .get(&client_id) + .map(|client| client.shell_graphics_delivery.clone()) + .unwrap_or_default(); + let mut surface_parts = None; + let frame = match mode { + ClientConnectionMode::ClientShell => { + let render_started = crate::render_prof::timer(); + let render_cell_size = if cell_size.is_known() { + cell_size + } else { + crate::kitty_graphics::HostCellSize::default() + }; + let crate::server::client_shell::RenderedPaneSurface { + frame, + panes, + splits, + popup, + graphics, + graphics_delivery: next_graphics_delivery, + } = render_client_shell_pane_surface( + &mut self.app, + area, + is_foreground, + render_cell_size, + &shell_graphics_delivery, + client_id, + ); + crate::render_prof::duration_since( + "full_render.render_tab_surface_virtual", + render_started, + ); + surface_parts = Some((panes, splits, popup, graphics, next_graphics_delivery)); + frame + } + ClientConnectionMode::TerminalPending => continue, + ClientConnectionMode::TerminalAttach { terminal_id } + | ClientConnectionMode::TerminalObserve { terminal_id } => { + let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { + self.send_to_client( + client_id, + ServerMessage::ServerShutdown { + reason: Some(format!( + "terminal attach ended: terminal {terminal_id} not found" + )), + }, + ); + broken_clients.push(client_id); + continue; + }; + let render_started = crate::render_prof::timer(); + let (buffer, cursor) = + crate::server::render_stream::render_terminal_virtual(runtime, area); + crate::render_prof::duration_since( + "full_render.render_terminal_virtual", + render_started, + ); + let hyperlinks_started = crate::render_prof::timer(); + let hyperlinks = runtime.visible_hyperlinks(area); + crate::render_prof::duration_since( + "full_render.visible_hyperlinks", + hyperlinks_started, + ); + let frame_started = crate::render_prof::timer(); + let frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &buffer, + cursor, + &hyperlinks, + ); + crate::render_prof::duration_since("full_render.frame_build", frame_started); + frame + } + }; + + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + let Some(writer) = client.writer.as_ref().cloned() else { + crate::render_prof::event("full_render.writer_missing"); + continue; + }; + let has_graphics = surface_parts + .as_ref() + .is_some_and(|(_, _, _, graphics, _)| { + !graphics.assets.is_empty() + || !graphics.placements.is_empty() + || !graphics.retained_assets.is_empty() + }); + let mut next_shell_graphics_delivery = None; + let prepared = if let Some((panes, splits, popup, graphics, delivery)) = surface_parts { + next_shell_graphics_delivery = Some(delivery); + client + .render_state + .prepare_pane_surface(protocol::PaneSurfaceFrame { + boot_id: self.client_shell_boot_id.clone(), + projection_revision: shell_projection_revision, + frame, + panes, + splits, + popup, + graphics, + }) + } else { + client.render_state.prepare_frame(frame) + }; + let Some(mut prepared) = prepared else { + client.clear_deferred_render(); + crate::render_prof::event("full_render.skip_identical"); + continue; + }; + let max = if has_graphics { + MAX_GRAPHICS_FRAME_SIZE + } else { + crate::protocol::MAX_FRAME_SIZE + }; + let mut shell_assets_deferred = false; + let serialized = match Self::frame_server_message_with_max(prepared.message(), max) { + Ok(frame) => frame, + Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => { + warn!( + client_id, + claimed, max, "dropping graphics assets from oversized pane surface" + ); + if !prepared.strip_pane_surface_assets() { + crate::render_prof::event("full_render.serialize_oversized"); + continue; + } + next_shell_graphics_delivery = None; + shell_assets_deferred = true; + match Self::frame_server_message(prepared.message()) { + Ok(framed) => framed, + Err(err) => { + warn!(client_id, err = %err, "failed to serialize pane surface without assets"); + broken_clients.push(client_id); + crate::render_prof::event("full_render.serialize_error"); + continue; + } + } + } + Err(protocol::FramingError::Oversized { claimed, max }) => { + warn!( + client_id, + claimed, max, "skipping oversized frame for client" + ); + crate::render_prof::event("full_render.serialize_oversized"); + continue; + } + Err(err) => { + warn!(client_id, err = %err, "failed to serialize frame"); + broken_clients.push(client_id); + crate::render_prof::event("full_render.serialize_error"); + continue; + } + }; + let shell_graphics_pending = next_shell_graphics_delivery + .as_ref() + .is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending); + match writer.render.try_send(serialized) { + Ok(()) => { + if let Some(delivery) = next_shell_graphics_delivery { + client.shell_graphics_delivery = delivery; + } + client.render_state.commit_sent_frame(prepared); + if shell_graphics_pending || shell_assets_deferred { + client.defer_full_render(); + deferred_frame = true; + } else { + client.clear_deferred_render(); + } + crate::render_prof::event("full_render.sent"); + } + Err(std::sync::mpsc::TrySendError::Full(_)) => { + client.defer_full_render(); + deferred_frame = true; + } + Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { + broken_clients.push(client_id); + } + } + } + + if !broken_clients.is_empty() { + for client_id in broken_clients { + self.remove_client_and_resize_if_needed(client_id); + } + } + + let (cols, rows) = self.effective_size; + if !deferred_frame { + self.app.full_redraw_pending = false; + } + crate::render_prof::duration_since("full_render.total", full_started); + debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)"); + } +} diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs new file mode 100644 index 00000000..eb0869e8 --- /dev/null +++ b/src/server/headless/tests/mod.rs @@ -0,0 +1,4461 @@ +use super::*; + +#[path = "pane_graphics.rs"] +mod pane_graphics_tests; + +fn test_headless_server() -> HeadlessServer { + test_headless_server_with_event_hub(api::EventHub::default()) +} + +fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServer { + let config = crate::config::Config::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let app = crate::app::App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); + + let dir = std::env::temp_dir().join(format!( + "hh-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = fs::create_dir_all(&dir); + let socket_path = dir.join("client.sock"); + let _ = fs::remove_file(&socket_path); + let listener = bind_local_listener(&socket_path).expect("bind test listener"); + let client_socket_identity = + socket_file_identity(&socket_path).expect("test listener socket identity"); + #[cfg(unix)] + listener + .set_nonblocking(ListenerNonblockingMode::Accept) + .expect("set listener nonblocking"); + let (server_event_tx, server_event_rx) = mpsc::channel(64); + let should_quit = Arc::new(AtomicBool::new(false)); + #[cfg(windows)] + spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()); + let server_keybindings = app_keybindings(&app); + let headless_size = app.state.headless_size; + + HeadlessServer { + app, + #[cfg(unix)] + api_tx: None, + api_server: None, + #[cfg(unix)] + client_listener: listener, + client_socket_path: socket_path, + client_socket_identity, + clients: HashMap::new(), + #[cfg(unix)] + next_client_id: 1, + foreground_client_id: None, + client_shell_boot_id: "test-boot".into(), + sent_window_title: None, + api_window_title: None, + server_keybindings, + server_config_diagnostic: None, + server_config_diagnostic_without_keybindings: None, + terminal_attach_owners: HashMap::new(), + pending_alt_screen_reads: Vec::new(), + deferred_alt_screen_reads: Vec::new(), + next_activity_stamp: 1, + headless_size, + effective_size: headless_size, + shutting_down: false, + handoff_in_progress: false, + #[cfg(unix)] + pending_handoff_repaint_nudge: false, + should_quit, + server_event_rx, + server_event_tx, + } +} + +fn shutdown_test_runtimes(server: &mut HeadlessServer) { + for (_, runtime) in server.app.terminal_runtimes.drain() { + runtime.shutdown(); + } +} + +fn read_server_message(bytes: Vec) -> ServerMessage { + let mut cursor = std::io::Cursor::new(bytes); + protocol::read_message(&mut cursor, MAX_FRAME_SIZE).expect("decode server message") +} + +fn frame_text(frame: &FrameData) -> String { + frame + .cells + .chunks(usize::from(frame.width)) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n") +} + +fn read_server_shutdown_reason(bytes: Vec) -> Option { + match read_server_message(bytes) { + ServerMessage::ServerShutdown { reason } => reason, + other => panic!("expected shutdown, got {other:?}"), + } +} + +#[test] +fn completed_handoff_disables_only_old_server_session_persistence() { + let mut server = test_headless_server(); + server.app.policy = crate::app::AppPolicy::PRODUCTION; + + server.finish_live_handoff_shutdown(); + + assert!(!server.app.policy.persist_session); + assert!(server.app.policy.restore_session); + assert!(server.app.policy.persist_plugin_registry); + assert!(server.app.policy.background_updates); +} + +#[test] +fn default_headless_size_is_effective_without_clients() { + let server = test_headless_server(); + + assert_eq!( + server.headless_size, + ( + crate::config::DEFAULT_HEADLESS_COLS, + crate::config::DEFAULT_HEADLESS_ROWS + ) + ); + assert_eq!(server.effective_size, server.headless_size); +} + +#[tokio::test] +async fn headless_api_reads_latest_title_without_spinner_event_flooding() { + let event_hub = api::EventHub::default(); + let mut server = test_headless_server_with_event_hub(event_hub.clone()); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("one")]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + server.app.state.sidebar_agents.rows = vec![vec![ + crate::config::AgentSidebarToken::TerminalTitleStripped, + ]]; + let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .detected_agent = Some(crate::detect::Agent::Claude); + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); + runtime.test_process_pty_bytes(b"\x1b]0;\xe2\xa0\x8b task\x07"); + server + .app + .terminal_runtimes + .insert(terminal_id.clone(), runtime); + server.app.render_dirty.request_terminal_title(pane_id); + + let first = headless_pane_list(&mut server).pop().unwrap(); + assert_eq!(first.terminal_title.as_deref(), Some("⠋ task")); + assert_eq!(first.terminal_title_stripped.as_deref(), Some("task")); + assert_eq!(pane_updated_events(&event_hub), 1); + server + .app + .terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\"); + server.app.render_dirty.request_terminal_title(pane_id); + let second = headless_pane_list(&mut server).pop().unwrap(); + assert_eq!(second.terminal_title.as_deref(), Some("⠙ task")); + assert_eq!(second.terminal_title_stripped.as_deref(), Some("task")); + assert_eq!(pane_updated_events(&event_hub), 1); +} + +fn headless_pane_list(server: &mut HeadlessServer) -> Vec { + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "list-titles".into(), + method: api::schema::Method::PaneList(api::schema::PaneListParams::default()), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + let response: api::schema::SuccessResponse = + serde_json::from_str(&response_rx.recv().unwrap()).unwrap(); + let api::schema::ResponseResult::PaneList { panes } = response.result else { + panic!("expected pane list"); + }; + panes +} + +fn pane_updated_events(event_hub: &api::EventHub) -> usize { + event_hub + .events_after(0) + .iter() + .filter(|(_, event)| event.event == api::schema::EventKind::PaneUpdated) + .count() +} + +#[test] +fn server_stop_interrupts_server_event_backlog() { + let mut server = test_headless_server(); + for client_id in 1..=64 { + server + .server_event_tx + .try_send(ServerEvent::ClientDisconnected { client_id }) + .unwrap(); + } + + server.should_quit.store(true, Ordering::Release); + + assert!(!server.drain_server_events()); + assert!(server.server_event_rx.try_recv().is_ok()); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn headless_api_request_drains_all_pending_internal_events_before_reading_state() { + let mut server = test_headless_server(); + for i in 0..=crate::app::APP_EVENT_DRAIN_LIMIT { + server + .app + .event_tx + .try_send(AppEvent::UpdateReady { + version: format!("4.0.{i}"), + install_command: "herdr install".into(), + }) + .unwrap(); + } + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + assert!( + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "headless_stop_after_events".into(), + method: api::schema::Method::ServerStop(api::schema::EmptyParams::default()), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }) + ); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "ok"); + let expected_version = format!("4.0.{}", crate::app::APP_EVENT_DRAIN_LIMIT); + assert_eq!( + server.app.state.update_available.as_deref(), + Some(expected_version.as_str()) + ); + assert!(server.app.event_rx.try_recv().is_err()); +} + +fn window_title_test_server() -> (HeadlessServer, std::sync::mpsc::Receiver>) { + let mut server = test_headless_server(); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + + let (client_tx, control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.promote_client_to_foreground(1); + drain_window_titles(&control_rx); + (server, control_rx) +} + +/// The test client writer drains its queue on a background thread, so +/// reading a pushed message needs a timeout rather than `try_recv`. +fn next_window_title(control_rx: &std::sync::mpsc::Receiver>) -> Option> { + let deadline = Instant::now() + Duration::from_secs(5); + while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { + let Ok(bytes) = control_rx.recv_timeout(remaining) else { + return None; + }; + if let ServerMessage::WindowTitle { title } = read_server_message(bytes) { + return Some(title); + } + } + None +} + +fn drain_window_titles(control_rx: &std::sync::mpsc::Receiver>) { + while control_rx.recv_timeout(Duration::from_millis(50)).is_ok() {} +} + +fn no_window_title(control_rx: &std::sync::mpsc::Receiver>) -> bool { + while let Ok(bytes) = control_rx.recv_timeout(Duration::from_millis(200)) { + if let ServerMessage::WindowTitle { .. } = read_server_message(bytes) { + return false; + } + } + true +} + +#[test] +fn window_title_waits_for_a_foreground_client_to_exist() { + let mut server = test_headless_server(); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; + server.app.state.active = Some(0); + server.app.configure_window_title("{workspace}"); + + // The server renders before the first client attaches. Nothing was + // delivered, so nothing may be recorded as delivered either. + server.sync_window_title(); + assert_eq!(server.sent_window_title, None); + + let (client_tx, control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.promote_client_to_foreground(1); + server.sync_window_title(); + + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd".to_string())) + ); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn an_attaching_client_gets_the_title_even_when_it_has_not_changed() { + let (mut server, first_control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + server.sync_window_title(); + assert_eq!( + next_window_title(&first_control_rx), + Some(Some("herd".to_string())) + ); + + // ClientConnected assigns the foreground client directly rather than + // going through promote_client_to_foreground, so the cache must notice + // the new client on its own. + let (client_tx, second_control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(2); + server.sync_window_title(); + + assert_eq!( + next_window_title(&second_control_rx), + Some(Some("herd".to_string())) + ); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn configured_window_title_reaches_the_foreground_client_once_per_change() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}/{tab}"); + + server.sync_window_title(); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd/1".to_string())) + ); + + // An unchanged title must not re-emit an OSC on every render. + server.sync_window_title(); + assert!(no_window_title(&control_rx)); + + server.app.state.workspaces[0].tabs[0].custom_name = Some("build".into()); + server.sync_window_title(); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd/build".to_string())) + ); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn focused_terminal_title_syncs_without_requesting_a_sidebar_render() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title("{terminal_title}"); + server.app.state.ensure_test_terminals(); + let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = server.app.state.workspaces[0] + .terminal_id(pane_id) + .expect("terminal") + .clone(); + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); + runtime.test_process_pty_bytes("\x1b]0;⠋ building\x07".as_bytes()); + server + .app + .terminal_runtimes + .insert(terminal_id.clone(), runtime); + + assert_eq!( + server.sync_terminal_title_sources(&HashSet::from([pane_id])), + (false, true) + ); + assert_eq!( + next_window_title(&control_rx), + Some(Some("building".to_string())) + ); + + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .test_process_pty_bytes("\x1b]0;⠙ building\x07".as_bytes()); + assert_eq!( + server.sync_terminal_title_sources(&HashSet::from([pane_id])), + (false, true) + ); + assert!(no_window_title(&control_rx)); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn a_foreground_client_without_a_writer_does_not_cache_the_window_title() { + let (mut server, _control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + + // A detached client keeps its entry but loses its writer, so nothing + // reaches a terminal even though the targeted send reports success. + if let Some(client) = server.clients.get_mut(&1) { + client.writer = None; + } + server.sync_window_title(); + assert!(server.sent_window_title.is_none()); + + // Attaching again has to deliver the title rather than skip it as sent. + let (client_tx, control_rx, _render_rx) = test_client_writer(); + if let Some(client) = server.clients.get_mut(&1) { + client.writer = Some(client_tx); + } + server.sync_window_title(); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd".to_string())) + ); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn empty_window_title_config_leaves_the_outer_title_alone() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title(""); + + server.sync_window_title(); + + assert!(no_window_title(&control_rx)); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn api_window_title_wins_until_it_is_cleared() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + + server.handle_client_window_title_api("set".into(), Some("herdr api".into())); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herdr api".to_string())) + ); + + server.app.state.workspaces[0].custom_name = Some("ops".into()); + server.sync_window_title(); + assert!(no_window_title(&control_rx)); + + // Clearing hands the title back to ui.window_title, not to "herdr". + server.handle_client_window_title_api("clear".into(), None); + assert_eq!( + next_window_title(&control_rx), + Some(Some("ops".to_string())) + ); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn clearing_the_api_title_falls_back_to_herdr_when_window_titles_are_disabled() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title(""); + + server.handle_client_window_title_api("set".into(), Some("herdr api".into())); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herdr api".to_string())) + ); + + server.handle_client_window_title_api("clear".into(), None); + assert_eq!(next_window_title(&control_rx), Some(None)); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn a_newly_promoted_client_gets_the_window_title_again() { + let (mut server, first_control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + server.sync_window_title(); + assert_eq!( + next_window_title(&first_control_rx), + Some(Some("herd".to_string())) + ); + + // A second terminal starts on whatever its shell or ssh left behind. + let (client_tx, second_control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.promote_client_to_foreground(2); + server.sync_window_title(); + + assert_eq!( + next_window_title(&second_control_rx), + Some(Some("herd".to_string())) + ); + shutdown_test_runtimes(&mut server); +} + +fn test_client_writer() -> ( + ClientWriter, + std::sync::mpsc::Receiver>, + std::sync::mpsc::Receiver>, +) { + let (control_tx, control_rx) = std::sync::mpsc::channel(); + let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1); + ( + ClientWriter::test_channel(control_tx, render_tx), + control_rx, + render_rx, + ) +} + +#[tokio::test] +async fn client_shell_attach_seeds_workspace() { + let mut server = test_headless_server(); + server.app.state.workspaces.clear(); + server.app.state.active = None; + server.app.state.mode = crate::app::Mode::Navigate; + let (writer, _control_rx, _render_rx) = test_client_writer(); + + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 6, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + + assert_eq!(server.app.state.mode, crate::app::Mode::Terminal); + assert_eq!(server.app.state.workspaces.len(), 1); + assert_eq!(server.app.state.active, Some(0)); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_endpoint_request_uses_the_selected_connection() { + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + let client_id = 41; + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + let _initial_snapshot = control_rx.recv().expect("initial shell snapshot"); + let boot_id = server.client_shell_boot_id.clone(); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id: boot_id.clone(), + request: Box::new(api::schema::Request { + id: "client-shell:1".into(), + method: api::schema::Method::IntegrationList(api::schema::EmptyParams::default(),), + }), + }) + ); + assert!(server.clients[&client_id].shell_endpoint_command_in_flight); + + let response_ready = server + .server_event_rx + .recv() + .await + .expect("endpoint response ready"); + assert!(!server.handle_server_event(response_ready)); + assert!(!server.clients[&client_id].shell_endpoint_command_in_flight); + + match read_server_message(control_rx.recv().expect("endpoint response")) { + ServerMessage::ClientShellEndpointResponseChunk { + boot_id: response_boot_id, + request_id, + final_chunk, + data, + } => { + assert_eq!(response_boot_id, boot_id); + assert_eq!(request_id, "client-shell:1"); + assert!(final_chunk); + let response = serde_json::from_slice::(&data) + .expect("success response"); + assert_eq!(response.id, "client-shell:1"); + assert!(matches!( + response.result, + api::schema::ResponseResult::IntegrationList { .. } + )); + } + other => panic!("expected client shell endpoint response, got {other:?}"), + } + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_receives_metadata_then_shell_free_pane_surface() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("shell-only-label"); + let pane_id = workspace.focused_pane_id().expect("focused pane"); + workspace.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + 80, + 23, + b"\x1b[?1003h\x1b[?1006h\x1b[?1016hCLIENT_SHELL_LIVE", + ), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + server.app.state.product_announcement = Some(crate::app::state::ProductAnnouncementState { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Client shell".into(), + body: "announcement".into(), + scroll: 0, + preview: true, + }); + server.server_config_diagnostic_without_keybindings = Some("endpoint config warning".into()); + + let (writer, control_rx, render_rx) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 7, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: true, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + match read_server_message(control_rx.recv().expect("shell snapshot")) { + ServerMessage::ClientShellSnapshot(snapshot) => { + assert_eq!(snapshot.workspaces.len(), 1); + assert_eq!(snapshot.workspaces[0].label, "shell-only-label"); + assert_eq!( + snapshot.config_diagnostic.as_deref(), + Some("endpoint config warning") + ); + assert_eq!( + snapshot.product_announcement.as_ref().map(|announcement| ( + announcement.version.as_str(), + announcement.id.as_str(), + announcement.preview, + )), + Some(("0.8.2", "client-shell", true)) + ); + } + other => panic!("expected client shell snapshot, got {other:?}"), + } + + server.render_and_stream(); + match read_server_message(render_rx.recv().expect("pane surface")) { + ServerMessage::PaneSurface(surface) => { + assert_eq!((surface.frame.width, surface.frame.height), (80, 23)); + let text = frame_text(&surface.frame); + assert!(text.contains("CLIENT_SHELL_LIVE"), "surface: {text:?}"); + assert!(!text.contains("shell-only-label"), "surface: {text:?}"); + assert_eq!(surface.panes.len(), 1); + assert_eq!(surface.panes[0].rect.x, 0); + assert_eq!(surface.panes[0].rect.y, 0); + assert!(surface.panes[0].sgr_pixel_mouse); + assert_eq!( + surface.panes[0].pixel_width, + u32::from(surface.panes[0].inner_rect.width) * 10 + ); + assert_eq!( + surface.panes[0].pixel_height, + u32::from(surface.panes[0].inner_rect.height) * 20 + ); + } + other => panic!("expected pane surface, got {other:?}"), + } + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_config_diagnostics_follow_keybinding_ownership() { + let mut server = test_headless_server(); + server.server_config_diagnostic = Some("server keybinding warning\ntheme warning".into()); + server.server_config_diagnostic_without_keybindings = Some("theme warning".into()); + + let (local_writer, local_control, _local_render) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 13, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer: local_writer, + }) + ); + let ServerMessage::ClientShellSnapshot(local_snapshot) = + read_server_message(local_control.recv().expect("local shell snapshot")) + else { + panic!("expected local shell snapshot"); + }; + assert_eq!( + local_snapshot.config_diagnostic.as_deref(), + Some("theme warning") + ); + + let (endpoint_writer, endpoint_control, _endpoint_render) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 14, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: false, + writer: endpoint_writer, + }) + ); + let ServerMessage::ClientShellSnapshot(endpoint_snapshot) = + read_server_message(endpoint_control.recv().expect("endpoint shell snapshot")) + else { + panic!("expected endpoint shell snapshot"); + }; + assert_eq!( + endpoint_snapshot.config_diagnostic.as_deref(), + Some("server keybinding warning\ntheme warning") + ); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_replaces_projection_and_focuses_stable_ids() { + let mut server = test_headless_server(); + let first = crate::workspace::Workspace::test_new("first"); + let second = crate::workspace::Workspace::test_new("second"); + server.app.state.workspaces = vec![first, second]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let second_id = server.app.session_snapshot().workspaces[1] + .workspace_id + .clone(); + + let (writer, control_rx, render_rx) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 9, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + let initial_revision = match read_server_message(control_rx.recv().expect("initial snapshot")) { + ServerMessage::ClientShellSnapshot(snapshot) => snapshot.revision, + other => panic!("expected initial shell snapshot, got {other:?}"), + }; + + let _ = server.app.handle_api_request(crate::api::schema::Request { + id: "test.client.shell.workspace.focus".into(), + method: crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: second_id.clone(), + }), + }); + assert_eq!(server.app.state.active, Some(1)); + server.render_and_stream(); + + let replacement = match read_server_message(control_rx.recv().expect("replacement snapshot")) { + ServerMessage::ClientShellSnapshot(snapshot) => snapshot, + other => panic!("expected replacement shell snapshot, got {other:?}"), + }; + assert!(replacement.revision > initial_revision); + assert_eq!( + replacement.focused_workspace_id.as_deref(), + Some(second_id.as_str()) + ); + match read_server_message(render_rx.recv().expect("replacement pane surface")) { + ServerMessage::PaneSurface(surface) => { + assert_eq!(surface.projection_revision, replacement.revision); + } + other => panic!("expected replacement pane surface, got {other:?}"), + } + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_input_targets_runtime_without_server_shell_classification() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1000h\x1b[?1006h"); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + + assert!( + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id, + events: vec![ + crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: None, + }, + crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), + kind: crate::protocol::ClientKeyKind::Release, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: None, + }, + crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: crossterm::event::KeyModifiers::ALT.bits(), + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: None, + }, + crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left, + ), + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }, + ], + }) + ); + assert_eq!( + input_rx.try_recv().expect("targeted pane interrupt"), + Bytes::from_static(&[0x03]) + ); + assert_eq!( + input_rx.try_recv().expect("targeted pane alt key"), + Bytes::from_static(b"\x1bx") + ); + assert_eq!( + input_rx.try_recv().expect("targeted pane mouse click"), + Bytes::from_static(b"\x1b[<0;3;2M") + ); + assert_eq!(server.foreground_client_id, Some(11)); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + assert!(server.paste_client_clipboard_image_path( + 11, + crate::protocol::ClientClipboardImageTarget::Pane(pane_id.clone()), + "/tmp/client-image.png".into(), + )); + assert_eq!( + input_rx.try_recv().expect("targeted clipboard image path"), + Bytes::from_static(b"/tmp/client-image.png") + ); + assert!(!server.paste_client_clipboard_image_path( + 11, + crate::protocol::ClientClipboardImageTarget::DirectTerminal, + "/tmp/wrong-target.png".into(), + )); + assert!(!server.paste_client_clipboard_image_path( + 11, + crate::protocol::ClientClipboardImageTarget::Popup("missing-popup".into()), + "/tmp/wrong-target.png".into(), + )); + assert!(input_rx.try_recv().is_err()); + + let (workspace_index, runtime_pane_id) = server + .app + .parse_pane_id(&pane_id) + .expect("runtime pane target"); + let runtime = server + .app + .state + .runtime_for_pane_in_workspace( + &server.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) + .expect("focused runtime"); + assert_eq!(runtime.current_size(), (24, 79)); + assert!(input_rx.try_recv().is_err(), "legacy release emitted bytes"); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_hidden_pane_rejects_presses_but_accepts_releases() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("hidden-input"); + let hidden_tab = workspace.test_add_tab(Some("hidden")); + let hidden_pane = workspace.tabs[hidden_tab].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[>3u", + 4, + ); + workspace.insert_test_runtime(hidden_pane, runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + let pane_id = server.app.public_pane_id(0, hidden_pane).unwrap(); + server.clients.insert( + 11, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + let key = |kind| crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: Some(0x2d), + }; + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id: pane_id.clone(), + events: vec![key(crate::protocol::ClientKeyKind::Press)], + }) + ); + assert!(input_rx.try_recv().is_err()); + assert!( + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id, + events: vec![key(crate::protocol::ClientKeyKind::Release)], + }) + ); + assert!(!input_rx.recv().await.expect("encoded release").is_empty()); + assert_eq!(server.foreground_client_id, None); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_streams_and_targets_popup_terminal_content() { + let mut server = test_headless_server(); + let mut pane_input = install_focused_test_runtime(&mut server, b"base-pane"); + let (popup_runtime, mut popup_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 40, + 12, + 0, + b"POPUP_SHELL_LIVE\x1b_Ga=T,f=32,t=d,i=9,p=4,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + 4, + ); + let (_, popup_terminal_id) = server.app.install_test_popup_runtime(popup_runtime); + + let (writer, control_rx, render_rx) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 12, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + assert!(matches!( + read_server_message(control_rx.recv().expect("shell snapshot")), + ServerMessage::ClientShellSnapshot(_) + )); + + server.render_and_stream(); + let ServerMessage::PaneSurface(surface) = + read_server_message(render_rx.recv().expect("popup surface")) + else { + panic!("expected pane surface"); + }; + let popup = surface.popup.as_deref().expect("popup terminal surface"); + assert_eq!(popup.terminal_id, popup_terminal_id.as_str()); + assert!(frame_text(&popup.frame).contains("POPUP_SHELL_LIVE")); + assert_eq!((popup.frame.width, popup.frame.height), (37, 9)); + assert_eq!(surface.graphics.assets.len(), 1); + assert_eq!(surface.graphics.placements.len(), 1); + assert!(matches!( + surface.graphics.placements[0].asset.source, + crate::protocol::SurfaceGraphicsSource::Terminal { + target: crate::protocol::SurfaceGraphicsTarget::Popup { .. }, + image_id: 9, + } + )); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 12, + pane_id: server.app.session_snapshot().focused_pane_id.unwrap(), + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "must-not-leak".into(), + )], + }) + ); + assert!(pane_input.try_recv().is_err()); + + assert!(server.handle_server_event(ServerEvent::ClientShellResize { + client_id: 12, + surface_cols: 60, + surface_rows: 15, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + })); + assert_eq!( + server + .app + .terminal_runtimes + .get(&popup_terminal_id) + .expect("popup runtime") + .current_size(), + (5, 27) + ); + + assert!( + server.handle_server_event(ServerEvent::ClientShellPopupInput { + client_id: 12, + terminal_id: popup_terminal_id.to_string(), + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "typed".into() + )], + }) + ); + assert_eq!( + popup_input.try_recv().expect("popup input"), + Bytes::from_static(b"typed") + ); + assert!(server.paste_client_clipboard_image_path( + 12, + crate::protocol::ClientClipboardImageTarget::Popup(popup_terminal_id.to_string()), + "/tmp/popup-image.png".into(), + )); + assert_eq!( + popup_input.try_recv().expect("popup clipboard image path"), + Bytes::from_static(b"/tmp/popup-image.png") + ); + assert!(!server.paste_client_clipboard_image_path( + 12, + crate::protocol::ClientClipboardImageTarget::Popup("stale-popup".into()), + "/tmp/wrong-popup.png".into(), + )); + assert!(popup_input.try_recv().is_err()); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPopupInput { + client_id: 12, + terminal_id: "stale-popup".into(), + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "wrong".into() + )], + }) + ); + assert!(popup_input.try_recv().is_err()); + + assert!(server.app.close_popup_pane()); + server.render_and_stream(); + let ServerMessage::PaneSurface(surface) = + read_server_message(render_rx.recv().expect("popup close surface")) + else { + panic!("expected pane surface after popup close"); + }; + assert!(surface.popup.is_none()); + shutdown_test_runtimes(&mut server); +} + +fn install_focused_test_runtime( + server: &mut HeadlessServer, + terminal_bytes: &[u8], +) -> tokio::sync::mpsc::Receiver { + let mut workspace = crate::workspace::Workspace::test_new("focus-reporting"); + let pane_id = workspace.tabs[0].root_pane; + let (runtime, input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + terminal_bytes, + 4, + ); + workspace.insert_test_runtime(pane_id, runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + input_rx +} + +fn retained_test_server( + initial_screen: &[u8], +) -> ( + HeadlessServer, + std::sync::mpsc::Receiver>, + crate::layout::PaneId, +) { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("test"); + let pane_id = workspace.focused_pane_id().expect("focused pane"); + workspace.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, initial_screen), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + + let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + + (server, client_rx, pane_id) +} + +#[test] +fn server_keybinding_filter_keeps_whole_config_failures() { + assert!(!config::is_keybinding_config_diagnostic( + "config parse error: invalid value at `keys.new_tab = @`; using defaults" + )); + assert!(!config::is_keybinding_config_diagnostic( + "config read error: permission denied at keys.toml; using defaults" + )); + assert!(config::is_keybinding_config_diagnostic( + "unsafe direct keybinding: keys.close_pane would intercept typing" + )); +} + +#[test] +fn client_shell_host_theme_follows_foreground_client() { + let mut server = test_headless_server(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + + let dark = protocol::ClientHostColor { + r: 20, + g: 30, + b: 40, + }; + let blue = protocol::ClientHostColor { + r: 10, + g: 20, + b: 200, + }; + assert!( + server.handle_server_event(ServerEvent::ClientShellHostTheme { + client_id: 1, + update: protocol::ClientHostThemeUpdate::DefaultColor { + kind: protocol::ClientHostDefaultColorKind::Background, + color: dark, + }, + }) + ); + assert!( + server.handle_server_event(ServerEvent::ClientShellHostTheme { + client_id: 1, + update: protocol::ClientHostThemeUpdate::PaletteColors(vec![(4, blue)]), + }) + ); + server.handle_server_event(ServerEvent::ClientShellHostTheme { + client_id: 1, + update: protocol::ClientHostThemeUpdate::Appearance(protocol::ClientHostAppearance::Dark), + }); + assert_eq!( + server.app.state.host_terminal_theme.background, + Some(dark.into()) + ); + assert_eq!( + server.app.state.host_terminal_theme.palette[4], + Some(blue.into()) + ); + assert_eq!( + server.app.state.host_terminal_appearance, + Some(crate::terminal_theme::HostAppearance::Dark) + ); + assert!(server.app.state.host_terminal_appearance_explicit); + + let light = protocol::ClientHostColor { + r: 240, + g: 230, + b: 220, + }; + assert!( + !server.handle_server_event(ServerEvent::ClientShellHostTheme { + client_id: 2, + update: protocol::ClientHostThemeUpdate::DefaultColor { + kind: protocol::ClientHostDefaultColorKind::Background, + color: light, + }, + }) + ); + assert_eq!( + server.app.state.host_terminal_theme.background, + Some(dark.into()) + ); + + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + assert_eq!( + server.app.state.host_terminal_theme.background, + Some(light.into()) + ); + assert_eq!( + server.app.state.host_terminal_appearance, + Some(crate::terminal_theme::HostAppearance::Light) + ); + assert!(!server.app.state.host_terminal_appearance_explicit); +} + +#[test] +fn terminal_clients_store_known_cell_geometry_independently_of_pixel_mouse() { + let mut server = test_headless_server(); + + let (writer, _control_rx, _render_rx) = test_client_writer(); + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: true, + writer, + })); + assert!(!server.clients[&7].pixel_mouse); + assert_eq!( + server.clients[&7].cell_size, + crate::kitty_graphics::HostCellSize::default() + ); + + let (writer, _control_rx, _render_rx) = test_client_writer(); + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id: 8, + cols: 80, + rows: 24, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: false, + writer, + })); + assert!(!server.clients[&8].pixel_mouse); + assert_eq!( + server.clients[&8].cell_size, + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + } + ); +} + +#[test] +fn terminal_attach_rejects_missing_terminal_and_removes_client() { + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + writer, + })); + assert!(matches!( + server.clients.get(&7).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalPending) + )); + + assert!( + !server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: "term_missing".to_owned(), + takeover: false, + }) + ); + assert!(!server.clients.contains_key(&7)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!( + reason, + Some("terminal attach failed: terminal term_missing not found".to_owned()) + ); +} + +fn with_terminal_session_test_server( + test: impl FnOnce(&mut HeadlessServer, crate::terminal::TerminalId, String, String), +) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("test"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); + let terminal_id_string = terminal_id.to_string(); + let public_pane_id = format!("{}:p1", workspace.id); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.terminal_runtimes.insert( + terminal_id.clone(), + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + + test(&mut server, terminal_id, terminal_id_string, public_pane_id); + + drop(server); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +fn connect_pending_terminal_client(server: &mut HeadlessServer, client_id: u64) { + let _control_rx = connect_pending_terminal_client_with_control_rx(server, client_id); +} + +fn connect_pending_terminal_client_with_control_rx( + server: &mut HeadlessServer, + client_id: u64, +) -> std::sync::mpsc::Receiver> { + let (writer, control_rx, _render_rx) = test_client_writer(); + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id, + cols: 100, + rows: 30, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + writer, + })); + assert!(matches!( + server.clients.get(&client_id).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalPending) + )); + control_rx +} + +#[test] +fn explicit_agent_history_read_requires_idle_on_alternate_screen() { + with_terminal_session_test_server( + |server, terminal_id, _terminal_id_string, public_pane_id| { + let terminal = server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("terminal"); + terminal.detected_agent = Some(crate::detect::Agent::Claude); + terminal.state = crate::detect::AgentState::Working; + server.app.terminal_runtimes.insert( + terminal_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + 80, + 24, + b"\x1b[?1049hworking", + ), + ); + let request = api::schema::Request { + id: "read".into(), + method: api::schema::Method::AgentRead(api::schema::AgentReadParams { + target: public_pane_id.clone(), + source: api::schema::ReadSource::Recent, + lines: Some(200), + format: api::schema::ReadFormat::Text, + strip_ansi: true, + }), + }; + + assert_eq!( + server.agent_read_not_idle_error(&request), + Some(api::schema::ErrorBody { + code: "agent_not_idle".into(), + message: format!( + "cannot read 200 lines while {public_pane_id} is working: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible" + ), + }) + ); + + let mut default_request = request.clone(); + let api::schema::Method::AgentRead(params) = &mut default_request.method else { + unreachable!(); + }; + params.lines = None; + assert_eq!(server.agent_read_not_idle_error(&default_request), None); + + let mut visible_request = request; + let api::schema::Method::AgentRead(params) = &mut visible_request.method else { + unreachable!(); + }; + params.source = api::schema::ReadSource::Visible; + assert_eq!(server.agent_read_not_idle_error(&visible_request), None); + }, + ); +} + +#[test] +fn terminal_attach_disconnect_restores_client_shell_pane_size() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("test"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); + let terminal_id_string = terminal_id.to_string(); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.terminal_runtimes.insert( + terminal_id.clone(), + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + let expected_shell_size = server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(); + + connect_pending_terminal_client(&mut server, 2); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 2, + terminal_id: terminal_id_string, + takeover: false, + }) + ); + assert_eq!(server.foreground_client_id, Some(1)); + assert!(server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + (30, 100) + ); + + assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 2 })); + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + expected_shell_size + ); + + drop(server); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn terminal_observe_allows_multiple_clients_without_attach_ownership() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + let initial_size = server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(); + + for client_id in [7, 8] { + connect_pending_terminal_client(server, client_id); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id, + target: terminal_id_string.clone(), + }) + ); + } + + assert!(server.terminal_attach_owners.is_empty()); + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + initial_size + ); + assert_eq!( + terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), + 2 + ); + }); +} + +#[test] +fn direct_terminal_observer_keeps_hidden_pty_source_renderable_with_client_shell() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("test"); + let background_tab = workspace.test_add_tab(Some("background")); + let background_pane = workspace.tabs[background_tab].root_pane; + let hidden_pane = workspace.tabs[0].root_pane; + let terminal_id = workspace + .terminal_id(background_pane) + .expect("background terminal id") + .to_string(); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.ensure_test_terminals(); + + let (shell_writer, _shell_control_rx, _shell_render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(shell_writer), + ), + ); + assert!(!server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); + + let (observer_writer, _observer_control_rx, _observer_render_rx) = test_client_writer(); + server.clients.insert( + 2, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalObserve { terminal_id }, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(observer_writer), + ), + ); + + assert!(server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); + server.sync_immediate_pty_sources(); + assert!(server.app.render_dirty.request_pty(background_pane)); + assert!(server.has_pending_presentation_work(false, false)); + assert!(server.app.render_dirty.request_pty(hidden_pane)); +} + +#[test] +fn terminal_observe_resolves_public_pane_id() { + with_terminal_session_test_server(|server, terminal_id, _, public_pane_id| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 7, + target: public_pane_id, + }) + ); + + assert!(matches!( + server.clients.get(&7).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalObserve { terminal_id: observed }) + if observed == &terminal_id.to_string() + )); + }); +} + +#[test] +fn terminal_control_resolves_public_pane_id_and_takes_ownership() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, public_pane_id| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: public_pane_id, + takeover: false, + }) + ); + + assert!(matches!( + server.clients.get(&7).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalAttach { terminal_id: attached }) + if attached == &terminal_id_string + )); + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&7) + ); + assert!(server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + }); +} + +#[test] +fn terminal_control_rejects_attach_during_alt_screen_read() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + let (respond_to, _response_rx) = std::sync::mpsc::channel(); + server.pending_alt_screen_reads.push( + crate::server::alt_screen_read::PendingAltScreenRead::start( + terminal_id, + "read".into(), + respond_to, + "fallback".into(), + api::schema::PaneReadResult { + pane_id: "w1:p1".into(), + workspace_id: "w1".into(), + tab_id: "w1:t1".into(), + source: api::schema::ReadSource::Recent, + format: api::schema::ReadFormat::Text, + text: String::new(), + revision: 0, + truncated: false, + }, + 120, + false, + crate::terminal::ScreenSnapshot { + cols: 80, + rows: Vec::new(), + }, + 0, + Instant::now(), + ), + ); + let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); + + assert!( + !server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + assert!(!server.clients.contains_key(&7)); + assert!(!server + .terminal_attach_owners + .contains_key(&terminal_id_string)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!( + reason, + Some(format!( + "terminal attach failed: terminal {terminal_id_string} has a read in progress; retry" + )) + ); + }); +} + +#[test] +fn terminal_control_rejects_second_controller_without_takeover() { + with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + connect_pending_terminal_client(server, 8); + assert!( + !server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 8, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + assert!(server.clients.contains_key(&7)); + assert!(!server.clients.contains_key(&8)); + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&7) + ); + }); +} + +#[test] +fn terminal_control_takeover_replaces_existing_controller() { + with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + connect_pending_terminal_client(server, 8); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 8, + target: terminal_id_string.clone(), + takeover: true, + }) + ); + + assert!(!server.clients.contains_key(&7)); + assert!(server.clients.contains_key(&8)); + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&8) + ); + }); +} + +#[test] +fn terminal_observe_can_coexist_with_terminal_control() { + with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + connect_pending_terminal_client(server, 8); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 8, + target: terminal_id_string.clone(), + }) + ); + + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&7) + ); + assert!(matches!( + server.clients.get(&8).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalObserve { terminal_id }) + if terminal_id == &terminal_id_string + )); + assert_eq!( + terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), + 2 + ); + }); +} + +#[test] +fn terminal_control_detach_sends_shutdown_before_removal() { + with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { + let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + assert!(server.handle_server_event(ServerEvent::ClientDetach { client_id: 7 })); + + assert!(!server.clients.contains_key(&7)); + assert!(!server + .terminal_attach_owners + .contains_key(&terminal_id_string)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!(reason, Some("detached".to_owned())); + }); +} + +#[test] +fn terminal_observe_rejects_later_attach_upgrade() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 7, + target: terminal_id_string.clone(), + }) + ); + assert!( + !server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: terminal_id_string, + takeover: true, + }) + ); + + assert!(!server.clients.contains_key(&7)); + assert!(server.terminal_attach_owners.is_empty()); + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + }); +} + +#[test] +fn terminal_attach_rejects_later_observe_and_clears_ownership() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: terminal_id_string.clone(), + takeover: false, + }) + ); + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&7) + ); + assert!(server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + + assert!( + !server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 7, + target: terminal_id_string.clone(), + }) + ); + + assert!(!server.clients.contains_key(&7)); + assert!(server.terminal_attach_owners.is_empty()); + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + }); +} + +#[test] +fn unchanged_git_refresh_does_not_request_headless_render() { + let mut server = test_headless_server(); + server.app.git_refresh_in_flight = true; + let mut workspace = crate::workspace::Workspace::test_new("one"); + let workspace_id = workspace.id.clone(); + let cwd = workspace.identity_cwd.clone(); + workspace.cached_auto_label = "cached".into(); + workspace.cached_git_status_key = cwd.clone(); + workspace.cached_git_branch = None; + server.app.state.workspaces.push(workspace); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed { + results: vec![crate::workspace::WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd: cwd.clone(), + status_cache_key: cwd, + demand: crate::workspace::GitStatusRefreshDemand::ALL, + auto_label: "cached".into(), + branch: None, + ahead_behind: None, + space: None, + }], + cache_updates: Vec::new(), + }); + + assert!(!changed); + assert!(!server.app.git_refresh_in_flight); +} + +#[test] +fn changed_git_refresh_requests_headless_render() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("one"); + let workspace_id = workspace.id.clone(); + let cwd = workspace.identity_cwd.clone(); + server.app.state.workspaces.push(workspace); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed { + results: vec![crate::workspace::WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd: cwd.clone(), + status_cache_key: cwd, + demand: crate::workspace::GitStatusRefreshDemand::ALL, + auto_label: "one".into(), + branch: Some("changed".into()), + ahead_behind: None, + space: None, + }], + cache_updates: Vec::new(), + }); + + assert!(changed); +} + +#[test] +fn terminal_attach_client_exits_when_attached_pane_dies() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("attached"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + let terminal_id = server.app.state.workspaces[0] + .pane_state(pane_id) + .expect("pane") + .attached_terminal_id + .to_string(); + let (writer, control_rx, _render_rx) = test_client_writer(); + + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + writer, + })); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: terminal_id.clone(), + takeover: false, + }) + ); + assert_eq!(server.terminal_attach_owners.get(&terminal_id), Some(&7)); + + assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id })); + + assert!(!server.clients.contains_key(&7)); + assert!(!server.terminal_attach_owners.contains_key(&terminal_id)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!(reason, Some(format!("terminal {terminal_id} exited"))); +} + +#[test] +fn terminal_attach_scroll_moves_attached_runtime_viewport() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let runtime = crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::Wheel, + AttachScrollDirection::Up, + 3, + None, + None, + 0, + ) + .expect("scroll up"); + let metrics = runtime.scroll_metrics().expect("scroll metrics"); + assert_eq!(metrics.offset_from_bottom, 3); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::Wheel, + AttachScrollDirection::Down, + 2, + None, + None, + 0, + ) + .expect("scroll down"); + let metrics = runtime.scroll_metrics().expect("scroll metrics"); + assert_eq!(metrics.offset_from_bottom, 1); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn client_pane_pixel_mouse_uses_runtime_pixel_encoding() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, + 5, + 0, + b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", + 4, + ); + runtime.resize(5, 20, 10, 20); + + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("pixel mouse input"); + assert_eq!( + input_rx.try_recv().expect("encoded pixel mouse"), + Bytes::from_static(b"\x1b[<35;21;22M") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn client_pane_pixel_mouse_falls_back_to_canonical_cell_position() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, + 5, + 0, + b"\x1b[?1003h\x1b[?1006h", + 4, + ); + runtime.resize(5, 20, 10, 20); + + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("cell mouse fallback"); + assert_eq!( + input_rx.try_recv().expect("encoded cell mouse"), + Bytes::from_static(b"\x1b[<35;3;2M") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn client_pane_wheel_input_accumulates_scrollback_offset() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + let scroll = |kind| crate::protocol::ClientPaneInputEvent::Mouse { + kind, + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }; + + apply_client_pane_input_events( + &runtime, + &[scroll(crate::protocol::ClientMouseKind::ScrollUp)], + ) + .expect("first scroll up"); + apply_client_pane_input_events( + &runtime, + &[scroll(crate::protocol::ClientMouseKind::ScrollUp)], + ) + .expect("second scroll up"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 6 + ); + + apply_client_pane_input_events( + &runtime, + &[scroll(crate::protocol::ClientMouseKind::ScrollDown)], + ) + .expect("scroll down"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 3 + ); + + runtime.test_process_pty_bytes(b"\x1b[?1003h\x1b[?1006h"); + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("reported mouse motion"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 3 + ); + assert_eq!( + input_rx.try_recv().expect("reported mouse motion"), + Bytes::from_static(b"\x1b[<35;3;2M") + ); + + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left), + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("mouse button"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("reported mouse button"), + Bytes::from_static(b"\x1b[<0;3;2M") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn terminal_attach_input_resets_scrolled_viewport() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + + runtime.scroll_up(4); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + + apply_terminal_attach_input(&runtime, b"x".to_vec()).expect("attach input"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded input"), + Bytes::from("x") + ); + + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +fn with_terminal_attach_runtime( + initial_bytes: &[u8], + initial_scroll: usize, + test: impl FnOnce(&crate::terminal::TerminalRuntime, &mut mpsc::Receiver), +) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = initial_bytes.to_vec(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + if initial_scroll > 0 { + runtime.scroll_up(initial_scroll); + } + + test(&runtime, &mut input_rx); + + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +fn apply_terminal_attach_page_up(runtime: &crate::terminal::TerminalRuntime) { + apply_terminal_attach_scroll( + runtime, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec(), + }, + AttachScrollDirection::Up, + 4, + None, + None, + 0, + ) + .expect("page key"); +} + +fn client_page_key( + code: crate::protocol::ClientKeyCode, + modifiers: crossterm::event::KeyModifiers, + kind: crate::protocol::ClientKeyKind, +) -> crate::protocol::ClientPaneInputEvent { + crate::protocol::ClientPaneInputEvent::Key { + code, + modifiers: modifiers.bits(), + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: None, + } +} + +#[test] +fn client_plain_page_keys_scroll_shell_transcript_by_pane_height() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("pane PageUp"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 5 + ); + + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Release, + )], + ) + .expect("pane PageUp release"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 5 + ); + + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageDown, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("pane PageDown"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert!(input_rx.try_recv().is_err(), "page keys reached the shell"); + }); +} + +#[test] +fn client_page_keys_forward_when_modified_or_owned_by_application() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::CONTROL, + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("modified pane PageUp"); + assert!( + input_rx.try_recv().is_ok(), + "modified PageUp was not forwarded" + ); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + }); + + with_terminal_attach_runtime(b"\x1b[?1h", 0, |runtime, input_rx| { + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("application PageUp"); + assert_eq!( + input_rx.try_recv().expect("forwarded application PageUp"), + Bytes::from_static(b"\x1b[5~") + ); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + }); +} + +#[test] +fn client_popup_plain_page_key_remains_popup_input() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_client_popup_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("popup PageUp"); + assert_eq!( + input_rx.try_recv().expect("forwarded popup PageUp"), + Bytes::from_static(b"\x1b[5~") + ); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + }); +} + +#[test] +fn terminal_attach_paste_uses_plain_text_when_runtime_did_not_enable_brackets() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) + .expect("attach paste"); + + assert_eq!( + input_rx.try_recv().expect("forwarded paste"), + Bytes::from_static(b"line one\nline two") + ); + }); +} + +#[test] +fn terminal_attach_paste_preserves_brackets_when_runtime_enabled_them() { + with_terminal_attach_runtime(b"\x1b[?2004h", 0, |runtime, input_rx| { + apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) + .expect("attach paste"); + + assert_eq!( + input_rx.try_recv().expect("forwarded paste"), + Bytes::from_static(b"\x1b[200~line one\nline two\x1b[201~") + ); + }); +} + +#[test] +fn terminal_attach_page_key_host_scrolls_plain_terminal() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + assert!(input_rx.try_recv().is_err()); + }); +} + +#[test] +fn terminal_attach_page_key_forwards_when_mouse_reporting() { + with_terminal_attach_runtime(b"\x1b[?1000h", 3, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded page key"), + Bytes::from_static(b"\x1b[5~") + ); + }); +} + +#[test] +fn terminal_attach_page_key_forwards_when_application_cursor() { + with_terminal_attach_runtime(b"\x1b[?1h", 3, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded page key"), + Bytes::from_static(b"\x1b[5~") + ); + }); +} + +#[test] +fn terminal_attach_page_key_host_scrolls_shell_like_decckm_with_bracketed_paste() { + with_terminal_attach_runtime(b"\x1b[?1h\x1b[?2004h", 0, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + assert!(input_rx.try_recv().is_err()); + }); +} + +#[test] +fn terminal_attach_page_key_forwards_in_alternate_screen_without_mouse_reporting() { + with_terminal_attach_runtime(b"\x1b[?1049h", 3, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded page key"), + Bytes::from_static(b"\x1b[5~") + ); + }); +} + +#[test] +fn headless_scheduled_tasks_expire_agent_metadata() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("metadata"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::HookStateReported { + pane_id, + source: "custom:pi".into(), + agent_label: "pi".into(), + state: crate::detect::AgentState::Working, + message: None, + seq: None, + session_ref: None, + }) + ); + assert!( + server.handle_internal_event_with_forwarding(AppEvent::HookMetadataReported { + pane_id, + source: "user:pi-display".into(), + agent_label: Some("pi".into()), + applies_to_source: Some("custom:pi".into()), + title: Some("short lived".into()), + display_agent: None, + state_labels: HashMap::new(), + clear_title: false, + clear_display_agent: false, + clear_state_labels: false, + seq: None, + // Expiry is advanced with the captured deadline below; keep the + // pre-expiry assertion independent of wall-clock scheduling. + ttl: Some(Duration::from_secs(60)), + }) + ); + + let deadline = server + .app + .agent_metadata_deadline + .expect("metadata deadline"); + let terminal_id = server.app.state.workspaces[0] + .pane_state(pane_id) + .expect("pane") + .attached_terminal_id + .clone(); + assert_eq!( + server + .app + .state + .terminals + .get(&terminal_id) + .expect("terminal") + .effective_title() + .as_deref(), + Some("short lived") + ); + + assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false)); + + assert_eq!(server.app.agent_metadata_deadline, None); + assert_eq!( + server + .app + .state + .terminals + .get(&terminal_id) + .expect("terminal") + .effective_title(), + None + ); + assert!(server + .app + .event_hub + .events_after(0) + .iter() + .any(|(_, event)| { + event.event == crate::api::schema::EventKind::PaneAgentStatusChanged + && matches!( + &event.data, + crate::api::schema::EventData::PaneAgentStatusChanged { + title, + .. + } if title.is_none() + ) + })); +} + +#[test] +fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() { + let mut server = test_headless_server(); + let now = Instant::now(); + server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1)); + + assert!(!server.handle_scheduled_tasks_headless(now, false)); + assert_eq!(server.app.next_agent_manifest_update_check, None); +} + +#[cfg(unix)] +#[tokio::test] +async fn headless_scheduled_tasks_start_pending_agent_resume_without_foreground_client() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("restored"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.ensure_test_terminals(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + server.render_and_stream(); + assert_ne!(server.app.state.view.terminal_area, Rect::default()); + + let now = Instant::now(); + assert!(!server.handle_scheduled_tasks_headless(now, false)); + assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); + let deadline = server + .app + .pending_agent_resume_deadline + .expect("clientless resume should wait briefly for a host theme"); + + assert!(server.handle_scheduled_tasks_headless(deadline, false)); + assert!(server.app.terminal_runtimes.get(&terminal_id).is_some()); + assert!(server + .app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_none()); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn terminal_attach_resize_uses_known_cell_geometry_without_pixel_mouse() { + with_terminal_session_test_server(|server, _other_terminal_id, terminal_id, _pane_id| { + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ); + client.mode = ClientConnectionMode::TerminalAttach { + terminal_id: terminal_id.clone(), + }; + server.clients.insert(1, client); + + assert!(server.handle_server_event(ServerEvent::ClientResize { + client_id: 1, + cols: 100, + rows: 30, + cell_width_px: 8, + cell_height_px: 16, + pixel_mouse: false, + })); + assert_eq!( + server + .runtime_for_terminal_id_string(&terminal_id) + .unwrap() + .pixel_size(), + Some((800, 480)) + ); + assert_eq!( + server.clients[&1].cell_size, + crate::kitty_graphics::HostCellSize { + width_px: 8, + height_px: 16, + } + ); + assert!(!server.clients[&1].pixel_mouse); + + assert!(server.handle_server_event(ServerEvent::ClientResize { + client_id: 1, + cols: 100, + rows: 30, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + })); + assert_eq!( + server + .runtime_for_terminal_id_string(&terminal_id) + .unwrap() + .pixel_size(), + None + ); + assert_eq!( + server.clients[&1].cell_size, + crate::kitty_graphics::HostCellSize::default() + ); + assert!(!server.clients[&1].pixel_mouse); + }); +} + +#[test] +fn pending_terminal_resize_does_not_take_shell_foreground_or_geometry() { + let mut server = test_headless_server(); + server.clients.insert( + 1, + ClientConnection::new( + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.clients.insert( + 2, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalPending, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::TerminalAnsi, + None, + ), + ); + server.foreground_client_id = Some(1); + server.resize_shared_runtime_to_effective_size(); + let shell_size = server.effective_size; + + assert!(server.handle_server_event(ServerEvent::ClientResize { + client_id: 2, + cols: 200, + rows: 60, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: false, + })); + + assert_eq!(server.foreground_client_id, Some(1)); + assert_eq!(server.effective_size, shell_size); + assert_eq!(server.clients[&2].terminal_size, (200, 60)); +} + +#[test] +fn client_shell_streams_focused_pane_report_all_demand() { + with_terminal_session_test_server(|server, _other_terminal_id, terminal_id, _pane_id| { + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.app.state.active = Some(0); + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("focused runtime") + .test_process_pty_bytes(b"\x1b[>15u"); + + server.stream_direct_terminal_keyboard_mode(); + + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("shell keyboard mode message") + ), + ServerMessage::ClientShellKeyboardReportAll { enabled: true } + )); + }); +} + +#[tokio::test] +async fn client_shell_release_cleanup_does_not_promote_and_survives_disconnect() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[>3u"); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + for client_id in [1, 2] { + server.clients.insert( + client_id, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + client_id, + RenderEncoding::SemanticFrame, + None, + ), + ); + } + let key = |kind| crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: (kind == crate::protocol::ClientKeyKind::Press).then(|| "x".to_owned()), + tracks_release: true, + physical_key_id: Some(0x2d), + }; + + assert!( + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 1, + pane_id: pane_id.clone(), + events: vec![key(crate::protocol::ClientKeyKind::Press)], + }) + ); + assert!(!input_rx.recv().await.expect("encoded press").is_empty()); + assert!(server.promote_client_to_foreground(2)); + + assert!( + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 1, + pane_id: pane_id.clone(), + events: vec![key(crate::protocol::ClientKeyKind::Release)], + }) + ); + assert!(!input_rx.recv().await.expect("encoded release").is_empty()); + assert_eq!(server.foreground_client_id, Some(2)); + + assert!( + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 1, + pane_id, + events: vec![key(crate::protocol::ClientKeyKind::Press)], + }) + ); + assert!(!input_rx + .recv() + .await + .expect("second encoded press") + .is_empty()); + assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 1 })); + assert!(!input_rx + .recv() + .await + .expect("disconnect synthesized release") + .is_empty()); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn client_shell_mouse_capture_combines_local_preference_with_endpoint_demand() { + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(writer), + ), + ); + + server.stream_host_mouse_capture_mode(); + assert!(matches!( + read_server_message(control_rx.recv().expect("initial mouse mode")), + ServerMessage::MouseCapture { + enabled: false, + sgr_pixels: false + } + )); + assert!( + server.handle_server_event(ServerEvent::ClientShellMouseCapture { + client_id: 1, + enabled: true, + }) + ); + server.stream_host_mouse_capture_mode(); + assert!(matches!( + read_server_message(control_rx.recv().expect("preferred mouse mode")), + ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: false + } + )); +} + +#[test] +fn client_shell_focus_promotes_and_reaches_reporting_pane() { + with_terminal_session_test_server(|server, terminal_id, _other_terminal_id, _pane_id| { + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1004h", + 4, + ); + server + .app + .terminal_runtimes + .insert(terminal_id.clone(), runtime); + server.app.state.active = Some(0); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("focused runtime") + .current_size(), + (30, 99) + ); + + assert!(server.handle_server_event(ServerEvent::ClientShellFocus { + client_id: 1, + focused: true, + })); + assert_eq!(server.foreground_client_id, Some(1)); + assert_eq!(server.app.state.outer_terminal_focus, Some(true)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("focused runtime") + .current_size(), + (24, 79) + ); + assert_eq!( + input_rx.try_recv().expect("focus gained input"), + Bytes::from_static(b"\x1b[I") + ); + + assert!(server.handle_server_event(ServerEvent::ClientShellFocus { + client_id: 1, + focused: false, + })); + assert_eq!(server.app.state.outer_terminal_focus, Some(false)); + assert_eq!( + input_rx.try_recv().expect("focus lost input"), + Bytes::from_static(b"\x1b[O") + ); + }); +} + +#[test] +fn direct_terminal_streams_child_keyboard_and_mouse_modes() { + with_terminal_session_test_server(|server, _other_terminal_id, terminal_id, _pane_id| { + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalAttach { + terminal_id: terminal_id.clone(), + }, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::TerminalAnsi, + Some(client_tx), + ), + ); + server + .clients + .get_mut(&1) + .expect("direct attach client") + .pixel_mouse = true; + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[>15u\x1b[?1000h"); + + server.stream_direct_terminal_keyboard_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("keyboard mode message") + ), + ServerMessage::DirectTerminalKeyboardProtocol { + flags: 15, + modify_other_keys_level: 0 + } + )); + + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[3u\x1b[>4;1m"); + server.stream_direct_terminal_keyboard_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("modifyOtherKeys mode-one keyboard message") + ), + ServerMessage::DirectTerminalKeyboardProtocol { + flags: 3, + modify_other_keys_level: 1 + } + )); + + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[>4;2m"); + server.stream_direct_terminal_keyboard_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("modifyOtherKeys mode-two keyboard message") + ), + ServerMessage::DirectTerminalKeyboardProtocol { + flags: 3, + modify_other_keys_level: 2 + } + )); + + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[ {} + other => panic!("expected ReloadSoundConfig, got {other:?}"), + } + assert!(!server.app.state.request_client_config_reload); +} + +#[test] +fn terminal_bell_targets_foreground_client_only() { + let mut server = test_headless_server(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(background_tx), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(2); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { + pane_id: crate::layout::PaneId::from_raw(1), + count: 3, + }); + + assert!(!changed); + match read_server_message( + foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("foreground terminal bell message"), + ) { + ServerMessage::TerminalBell { count } => assert_eq!(count, 3), + other => panic!("expected terminal bell message, got {other:?}"), + } + assert!( + background_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "background client should not receive terminal bells" + ); + + server.foreground_client_id = None; + server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { + pane_id: crate::layout::PaneId::from_raw(1), + count: 1, + }); + assert!( + foreground_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "bells without a foreground client must not be retained" + ); +} + +#[test] +fn clipboard_write_targets_foreground_client_only() { + let mut server = test_headless_server(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(background_tx), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { + content: b"test".to_vec(), + }); + + assert!(!changed); + match read_server_message( + foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("foreground clipboard message"), + ) { + ServerMessage::Clipboard { data } => assert_eq!(data, "dGVzdA=="), + other => panic!("expected clipboard message, got {other:?}"), + } + assert!( + background_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "background client should not receive clipboard writes" + ); +} + +#[test] +fn clipboard_write_without_foreground_client_does_not_change_visual_state() { + let mut server = test_headless_server(); + server.foreground_client_id = None; + + let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { + content: b"test".to_vec(), + }); + + assert!(!changed); +} + +#[test] +fn clipboard_write_failed_foreground_send_removes_client_without_visual_change() { + let mut server = test_headless_server(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + drop(foreground_control_rx); + foreground_tx.test_close(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(1); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { + content: b"test".to_vec(), + }); + + assert!(!changed); + assert!( + !server.clients.contains_key(&1), + "failed targeted send should remove the broken foreground client" + ); +} + +#[test] +fn semantic_notifications_broadcast_only_to_client_shells() { + let mut server = test_headless_server(); + let (shell_one_tx, shell_one_control, _shell_one_frames) = test_client_writer(); + let (shell_two_tx, shell_two_control, _shell_two_frames) = test_client_writer(); + let (terminal_tx, terminal_control, _terminal_frames) = test_client_writer(); + for (client_id, writer) in [(1, shell_one_tx), (2, shell_two_tx)] { + server.clients.insert( + client_id, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + client_id, + RenderEncoding::SemanticFrame, + Some(writer), + ), + ); + } + server.clients.insert( + 3, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalPending, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 3, + RenderEncoding::TerminalAnsi, + Some(terminal_tx), + ), + ); + let event = protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::Custom, + title: "hello".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }; + assert!(server.send_to_client_shells(ServerMessage::SemanticNotification(event.clone()))); + for receiver in [shell_one_control, shell_two_control] { + assert_eq!( + read_server_message( + receiver + .recv_timeout(Duration::from_millis(100)) + .expect("semantic notification") + ), + ServerMessage::SemanticNotification(event.clone()) + ); + } + assert!(terminal_control + .recv_timeout(Duration::from_millis(50)) + .is_err()); +} + +#[test] +fn notification_show_uses_client_shell_policy_independent_of_server_delivery() { + let mut server = test_headless_server(); + server.app.state.toast_config.delivery = config::ToastDelivery::Off; + let (shell_tx, shell_control, _shell_frames) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(shell_tx), + ), + ); + let response = server.handle_notification_show_api( + "notify-shell".into(), + api::schema::NotificationShowParams { + title: "plugin title".into(), + body: Some("plugin body".into()), + position: Some(crate::config::ToastHerdrPosition::TopLeft), + sound: api::schema::NotificationShowSound::Done, + }, + ); + let response: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!( + response.result, + api::schema::ResponseResult::NotificationShow { shown: true, .. } + )); + assert_eq!( + read_server_message( + shell_control + .recv_timeout(Duration::from_millis(100)) + .expect("semantic plugin notification") + ), + ServerMessage::SemanticNotification(protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::Custom, + title: "plugin title".into(), + body: Some("plugin body".into()), + sound: Some(protocol::SemanticNotificationSound::Done), + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: Some(crate::config::ToastHerdrPosition::TopLeft), + }) + ); +} + +#[test] +fn client_local_notifications_target_foreground_client_only() { + let mut server = test_headless_server(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(background_tx), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + assert!(server.send_to_foreground_client(ServerMessage::Notify { + kind: protocol::NotifyKind::Toast, + message: "pi finished".to_string(), + body: Some("workspace 1".to_string()), + })); + + match read_server_message( + foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("foreground toast message"), + ) { + ServerMessage::Notify { + kind, + message, + body, + } => { + assert_eq!(kind, protocol::NotifyKind::Toast); + assert_eq!(message, "pi finished"); + assert_eq!(body.as_deref(), Some("workspace 1")); + } + other => panic!("expected toast notify, got {other:?}"), + } + assert!( + background_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "background client should not receive client-local notifications" + ); +} + +#[test] +fn oversized_paste_rejection_notifies_only_the_sending_client() { + let mut server = test_headless_server(); + let (sender_writer, sender_control_rx, _sender_render_rx) = test_client_writer(); + let (foreground_writer, foreground_control_rx, _foreground_render_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(sender_writer), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(foreground_writer), + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + assert!( + !server.handle_server_event(ServerEvent::ClientPasteRejected { + client_id: 1, + size: 5_000_012, + max: 1_048_576, + }) + ); + + match read_server_message( + sender_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("sending client rejection notification"), + ) { + ServerMessage::ClientShellError { message } => assert_eq!( + message, + "Paste rejected: Input message is 5000012 bytes; Herdr's limit is 1048576 bytes" + ), + other => panic!("expected client shell paste error, got {other:?}"), + } + let (shell_writer, shell_control_rx, _shell_render_rx) = test_client_writer(); + server.clients.insert( + 3, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + 3, + RenderEncoding::SemanticFrame, + Some(shell_writer), + ), + ); + assert!( + !server.handle_server_event(ServerEvent::ClientPasteRejected { + client_id: 3, + size: 7_000_000, + max: 1_048_576, + }) + ); + match read_server_message( + shell_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("client shell rejection error"), + ) { + ServerMessage::ClientShellError { message } => assert_eq!( + message, + "Paste rejected: Input message is 7000000 bytes; Herdr's limit is 1048576 bytes" + ), + other => panic!("expected client shell paste error, got {other:?}"), + } + assert!( + foreground_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "foreground client must not receive another client's rejection" + ); + assert_eq!(server.foreground_client_id, Some(2)); + assert_eq!(server.clients.len(), 3); + assert!(server.app.state.toast.is_none()); +} + +#[test] +fn update_notification_reaches_client_shell_independent_of_delivery() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + + let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { + version: "9.9.9".to_string(), + install_command: "herdr update".into(), + }); + + assert!(changed); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic update notification") + ), + ServerMessage::SemanticNotification(protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::UpdateInstalled, + .. + }) + )); +} + +#[test] +fn update_notification_is_semantic_for_system_delivery() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + + let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { + version: "9.9.9".to_string(), + install_command: "herdr update".into(), + }); + + assert!(changed); + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic update notification"), + ) { + ServerMessage::SemanticNotification(notification) => { + assert_eq!( + notification.kind, + protocol::SemanticNotificationKind::UpdateInstalled + ); + assert_eq!(notification.title, "Herdr v9.9.9 available"); + assert_eq!( + notification.body.as_deref(), + Some("detach, run `herdr update`, then follow its restart guidance") + ); + } + other => panic!("expected semantic update notification, got {other:?}"), + } +} + +#[test] +fn notification_show_api_forwards_one_semantic_client_notification() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams { + title: "build failed".into(), + body: Some("api workspace".into()), + position: Some(crate::config::ToastHerdrPosition::TopLeft), + sound: api::schema::NotificationShowSound::Request, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + api::schema::ResponseResult::NotificationShow { + shown: true, + reason: api::schema::NotificationShowReason::Shown, + } + ); + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic api notification"), + ) { + ServerMessage::SemanticNotification(notification) => { + assert_eq!(notification.title, "build failed"); + assert_eq!(notification.body.as_deref(), Some("api workspace")); + assert_eq!( + notification.sound, + Some(protocol::SemanticNotificationSound::Request) + ); + } + other => panic!("expected semantic api notification, got {other:?}"), + } +} + +#[test] +fn notification_show_api_preserves_colon_in_forwarded_title() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams { + title: "build: failed".into(), + body: Some("api workspace".into()), + position: None, + sound: api::schema::NotificationShowSound::None, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + api::schema::ResponseResult::NotificationShow { + shown: true, + reason: api::schema::NotificationShowReason::Shown, + } + ); + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic api notification"), + ) { + ServerMessage::SemanticNotification(notification) => { + assert_eq!(notification.title, "build: failed"); + assert_eq!(notification.body.as_deref(), Some("api workspace")); + } + other => panic!("expected semantic api notification, got {other:?}"), + } +} + +#[test] +fn notification_show_api_validates_empty_title_before_disabled_delivery() { + let mut server = test_headless_server(); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::Off; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams { + title: "\n\t".into(), + body: None, + position: None, + sound: api::schema::NotificationShowSound::None, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(parsed.error.code, "invalid_params"); + assert_eq!(parsed.error.message, "notification title is empty"); +} + +#[test] +fn notification_show_api_reports_no_foreground_client() { + let mut server = test_headless_server(); + server.foreground_client_id = None; + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: api::schema::NotificationShowSound::Request, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + api::schema::ResponseResult::NotificationShow { + shown: false, + reason: api::schema::NotificationShowReason::NoForegroundClient, + } + ); +} + +#[test] +fn notification_show_api_includes_sound_in_semantic_event() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + assert!( + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow( + api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: api::schema::NotificationShowSound::Done, + }, + ), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }) + ); + + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + api::schema::ResponseResult::NotificationShow { + shown: true, + reason: api::schema::NotificationShowReason::Shown, + } + ); + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic api notification"), + ) { + ServerMessage::SemanticNotification(notification) => { + assert_eq!(notification.title, "build failed"); + assert_eq!( + notification.sound, + Some(protocol::SemanticNotificationSound::Done) + ); + } + other => panic!("expected semantic api notification, got {other:?}"), + } +} + +#[test] +fn startup_idle_does_not_forward_completion() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("active"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + server.app.state.toast_config.delay_seconds = 0; + server.app.state.sound.enabled = true; + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected { + pane_id, + agent: crate::detect::Agent::Pi, + observed_at: Instant::now(), + }) + ); + + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + while client_control_rx + .recv_timeout(Duration::from_millis(20)) + .is_ok() + {} + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::StateChanged { + pane_id, + agent: Some(crate::detect::Agent::Pi), + state: crate::detect::AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: Instant::now(), + }) + ); + assert!( + client_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "startup readiness should not forward a completion notification" + ); +} + +#[test] +fn stale_api_agent_report_does_not_forward_done_sound() { + let mut server = test_headless_server(); + let background = crate::workspace::Workspace::test_new("background"); + let pane_id = background.tabs[0].root_pane; + let public_pane_id = format!("{}:p1", background.id); + let foreground = crate::workspace::Workspace::test_new("foreground"); + server.app.state.workspaces = vec![background, foreground]; + server.app.state.ensure_test_terminals(); + let terminal_id = server.app.state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .attached_terminal_id + .clone(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:pi".into(), + agent: "pi".into(), + session_ref: crate::agent_resume::AgentSessionRef::path( + std::env::current_dir() + .unwrap() + .join("headless-pi-session.jsonl") + .display() + .to_string(), + ) + .unwrap(), + }); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_hook_authority( + "herdr:pi".into(), + "pi".into(), + crate::detect::AgentState::Working, + None, + Some(20), + ); + server.app.state.active = Some(1); + server.app.state.selected = 1; + server.app.state.mode = crate::app::Mode::Terminal; + + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "stale".into(), + method: api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams { + pane_id: public_pane_id, + source: "herdr:pi".into(), + agent: "pi".into(), + state: api::schema::PaneAgentState::Idle, + message: None, + seq: Some(19), + agent_session_id: None, + agent_session_path: None, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok()); + assert_eq!( + server.app.state.terminals.get(&terminal_id).unwrap().state, + crate::detect::AgentState::Working + ); + assert!( + client_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "stale idle report must not forward a done sound" + ); +} + +/// Verify that calls to the app's internal-event methods only occur inside +/// `handle_internal_event_with_forwarding`. This ensures the forwarding +/// bypass cannot be reintroduced. +#[test] +fn no_handle_internal_event_bypass_in_module() { + let source = include_str!("../../headless.rs"); + + // Find all lines containing handle_internal_event + let mut bypass_lines: Vec = Vec::new(); + let mut inside_forwarding_method = false; + let mut forwarding_method_brace_depth = 0u32; + + for (i, line) in source.lines().enumerate() { + let line_num = i + 1; + + // Track when we're inside handle_internal_event_with_forwarding + if line.contains("fn handle_internal_event_with_forwarding") { + inside_forwarding_method = true; + forwarding_method_brace_depth = 0; + } + + if inside_forwarding_method { + // Count braces to track when we exit the method + for ch in line.chars() { + match ch { + '{' => forwarding_method_brace_depth += 1, + '}' => { + forwarding_method_brace_depth = + forwarding_method_brace_depth.saturating_sub(1); + if forwarding_method_brace_depth == 0 { + inside_forwarding_method = false; + } + } + _ => {} + } + } + } else if (line.contains("self.app.handle_internal_event(") + || line.contains("self.app.handle_internal_event_with_render_impact(")) + && !line.trim().starts_with("///") + && !line.contains("contains(") + { + // Internal-event call outside the forwarding method. + bypass_lines.push(format!("line {}: {}", line_num, line.trim())); + } + } + + assert!( + bypass_lines.is_empty(), + "Found direct calls to self.app.handle_internal_event outside \ + handle_internal_event_with_forwarding (bypass risk):\n {}", + bypass_lines.join("\n ") + ); +} diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index 8a6f4f45..ecdbc2b7 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -4,51 +4,6 @@ fn receive_render(receiver: &std::sync::mpsc::Receiver>, timeout: Durati receiver.recv_timeout(timeout).unwrap() } -#[tokio::test] -async fn cold_redraw_advances_one_bounded_layer_after_each_send() { - let (mut server, client_rx, pane_id) = retained_test_server(b"cold redraw"); - server.app.state.kitty_graphics_enabled = true; - server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - const LAYERS: usize = 8; - for index in 0..LAYERS { - set_named_graphics_layer( - &mut server, - pane_id, - &format!("layer-{index:02}"), - vec![index as u8; 1024 * 1024], - index as i32, - ); - } - - fill_render_lane(&server); - server.render_and_stream(); - assert!(server.clients[&1].graphics_cache.is_empty()); - let _older = client_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - - for expected in 1..=LAYERS { - server.render_and_stream(); - let bytes = client_rx.recv_timeout(Duration::from_secs(5)).unwrap(); - assert!(bytes.len() <= MAX_GRAPHICS_FRAME_SIZE + 4); - let frame = read_server_frame(bytes); - assert_eq!( - frame - .graphics - .windows(4) - .filter(|part| *part == b"a=t,") - .count(), - 1 - ); - assert_eq!( - server.clients[&1].graphics_cache.test_image_count(), - expected - ); - } - assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); -} - #[tokio::test] async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once() { let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); @@ -216,19 +171,6 @@ async fn full_client_shell_render_lane_does_not_commit_graphics_delivery() { assert_eq!(surface.graphics.assets[0].data, vec![5, 6, 7, 8]); } -fn enable_graphics_and_render( - server: &mut HeadlessServer, - client_rx: &std::sync::mpsc::Receiver>, -) -> FrameData { - server.app.state.kitty_graphics_enabled = true; - server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - server.render_and_stream(); - read_server_frame(receive_render(client_rx, Duration::from_millis(100))) -} - fn graphics_key(pane_id: crate::layout::PaneId) -> crate::app::pane_graphics::Key { (pane_id, api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID.into()) } @@ -425,151 +367,6 @@ async fn pixel_mouse_activation_requires_graphics_demand_not_direct_transport() )); } -#[tokio::test] -async fn pixel_input_metadata_cannot_resize_authoritative_client_state() { - let (mut server, _client_rx, pane_id) = - retained_test_server(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h"); - set_graphics_layer(&mut server, pane_id, vec![1]); - let client = server.clients.get_mut(&1).unwrap(); - client.pixel_mouse = true; - client.host_sgr_pixels_active = Some(true); - server.foreground_client_id = None; - assert!(!server.handle_server_event(ServerEvent::ClientInputPixels { - client_id: 1, - data: b"\x1b[<0;500;300M".to_vec(), - geometry: crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(), - })); - server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - for (geometry, data) in [ - ( - crate::input::mouse::HostGeometry::new(100, 30, 1_000, 600).unwrap(), - b"\x1b[<0;500;300M".as_slice(), - ), - ( - crate::input::mouse::HostGeometry::new(80, 24, 960, 480).unwrap(), - b"\x1b[<0;500;300M", - ), - ( - crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(), - b"\x1b[<0;0;1M", - ), - ] { - assert!(!server.handle_server_event(ServerEvent::ClientInputPixels { - client_id: 1, - data: data.to_vec(), - geometry, - })); - } - assert_eq!(server.clients[&1].terminal_size, (80, 24)); - assert_eq!( - (server.effective_size, server.foreground_client_id), - ((80, 24), None) - ); -} - -#[test] -fn direct_eligibility_is_installed_with_the_client_connection() { - let mut server = test_headless_server(); - let (writer, _control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 10, - cell_height_px: 20, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: true, - writer, - })); - - let client = server.clients.get(&7).expect("connected client"); - assert!(client.direct_graphics); - assert_eq!(server.foreground_client_id, Some(7)); - assert!(server.app.direct_graphics_available); -} - -#[tokio::test] -async fn focus_repaint_preserves_uploaded_graphics() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - let (client_2_writer, _client_2_control_rx, client_2_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - crate::terminal_theme::TerminalTheme::default(), - Some(false), - 0, - RenderEncoding::SemanticFrame, - Some(client_2_writer), - ), - ); - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); - let initial = enable_graphics_and_render(&mut server, &client_rx); - let initial_graphics = String::from_utf8_lossy(&initial.graphics); - assert!(initial_graphics.contains("a=t")); - assert!(initial_graphics.contains("a=p")); - let client_2_initial = - read_server_frame(receive_render(&client_2_rx, Duration::from_millis(100))); - assert!(String::from_utf8_lossy(&client_2_initial.graphics).contains("a=t")); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 2, - data: b"\x1b[I".to_vec(), - })); - assert_eq!(server.foreground_client_id, Some(2)); - server.render_and_stream(); - - let focused = read_server_frame(receive_render(&client_2_rx, Duration::from_millis(100))); - let focused_graphics = String::from_utf8_lossy(&focused.graphics); - assert!(focused_graphics.contains("a=p")); - assert!(!focused_graphics.contains("a=t")); -} - -#[tokio::test] -async fn resize_replays_placement_without_retransmitting_or_closing_stream() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); - set_stream_owner(&mut server, pane_id, "owner-resize"); - let initial = enable_graphics_and_render(&mut server, &client_rx); - assert!(String::from_utf8_lossy(&initial.graphics).contains("a=t")); - - for (cols, rows, cell_width_px, cell_height_px) in - [(100, 30, 10, 20), (100, 30, 12, 24), (100, 30, 12, 24)] - { - assert!(server.handle_server_event(ServerEvent::ClientResize { - client_id: 1, - cols, - rows, - cell_width_px, - cell_height_px, - })); - server.render_and_stream(); - let frame = read_server_frame(receive_render(&client_rx, Duration::from_millis(100))); - let graphics = String::from_utf8_lossy(&frame.graphics); - assert!(!graphics.contains("a=t")); - assert!(graphics.contains("a=p")); - } - assert_eq!( - server - .app - .pane_graphics - .slots - .get(&graphics_key(pane_id)) - .and_then(|slot| slot.stream_owner.as_deref()), - Some("owner-resize") - ); -} - #[tokio::test] async fn graphics_pruning_preserves_live_panes_and_removes_closed_panes() { let (mut server, _client_rx, pane_id) = retained_test_server(b"aaaa"); @@ -593,88 +390,6 @@ async fn graphics_pruning_preserves_live_panes_and_removes_closed_panes() { assert!(server.app.pane_graphics.slots.is_empty()); } -#[tokio::test] -async fn retained_update_sends_only_graphics_message() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - let baseline = enable_graphics_and_render(&mut server, &client_rx); - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); - - assert_eq!( - server.render_retained_graphics_update_and_stream(), - RetainedGraphicsOutcome::Sent - ); - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("graphics-only update"), - ) { - ServerMessage::Graphics { bytes } => { - assert!(bytes.windows(3).any(|window| window == b"\x1b_G")); - } - other => panic!("expected graphics-only message, got {other:?}"), - } - assert_frame_data_eq( - server - .clients - .get(&1) - .unwrap() - .render_state - .last_frame() - .expect("semantic baseline"), - &baseline, - ); -} - -#[tokio::test] -async fn retained_graphics_stays_ordered_after_an_older_render() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - let _ = enable_graphics_and_render(&mut server, &client_rx); - fill_render_lane(&server); - set_graphics_layer(&mut server, pane_id, vec![4, 5, 6]); - let older = client_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert_eq!( - server.render_retained_graphics_update_and_stream(), - RetainedGraphicsOutcome::Sent - ); - let graphics = client_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!( - read_server_message(older), - ServerMessage::ReloadSoundConfig - )); - assert!(matches!( - read_server_message(graphics), - ServerMessage::Graphics { .. } - )); -} - -#[tokio::test] -async fn retained_update_falls_back_for_mixed_app_geometry() { - let (mut server, client_rx, _pane_id) = retained_test_server(b"aaaa"); - let _ = enable_graphics_and_render(&mut server, &client_rx); - - let (writer, _control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (60, 20), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(writer), - ), - ); - - assert_eq!( - server.render_retained_graphics_update_and_stream(), - RetainedGraphicsOutcome::Fallback - ); -} - #[test] fn stream_open_gate_is_owned_by_the_layer_and_cancels_on_removal() { let mut server = test_headless_server(); @@ -909,6 +624,7 @@ async fn client_shell_direct_graphics_uploads_without_server_authored_coordinate height_px: 20, }; client.direct_graphics = true; + client.pixel_mouse = true; server.app.direct_graphics_available = true; set_stream_owner(&mut server, pane_id, "browser"); let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); @@ -995,252 +711,6 @@ async fn client_shell_direct_graphics_uploads_without_server_authored_coordinate assert_eq!(hidden.retained_assets, vec![asset]); } -#[cfg(unix)] -#[tokio::test] -async fn hidden_large_direct_frame_uploads_then_replays_placement_without_closing_stream() { - let (mut server, client_rx, _) = retained_test_server(b"active"); - enable_graphics_and_render(&mut server, &client_rx); - let background_tab = server.app.state.workspaces[0].test_add_tab(Some("browser")); - let pane_id = server.app.state.workspaces[0].tabs[background_tab].root_pane; - let pane_number = server.app.state.workspaces[0] - .public_pane_number(pane_id) - .unwrap(); - let public_pane_id = crate::workspace::public_pane_id_for_number( - &server.app.state.workspaces[0].id, - pane_number, - ); - server.clients.get_mut(&1).unwrap().direct_graphics = true; - server.app.direct_graphics_available = true; - set_stream_owner(&mut server, pane_id, "browser"); - - let image_width = 2_048; - let image_height = 2_049; - let expected_len = u64::from(image_width) * u64::from(image_height) * 4; - assert!(expected_len > api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES as u64); - let path = sparse_direct_frame( - &server, - "hidden-large-frame.rgba", - image_width, - image_height, - ); - let (message, response_rx) = direct_stream_message( - "hidden-frame", - &public_pane_id, - "browser", - path, - image_width, - image_height, - ); - - assert_eq!( - server.handle_pane_graphics_stream_frame(message), - RenderImpact::None - ); - let (transfer_id, image_id, control, leading) = match read_server_message( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("hidden direct upload"), - ) { - ServerMessage::GraphicsFile { - transfer_id, - image_id, - control, - leading, - expected_len: sent_len, - .. - } => { - assert_eq!(sent_len, expected_len); - (transfer_id, image_id, control, leading) - } - other => panic!("expected graphics file, got {other:?}"), - }; - assert!(leading.is_empty()); - assert!(control.starts_with("a=t,"), "{control}"); - assert!(!control.contains("p="), "{control}"); - assert!(response_rx.try_recv().is_err()); - - server.app.state.workspaces[0].switch_tab(background_tab); - server.render_and_stream(); - let frame = read_server_frame( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("frame while upload is pending"), - ); - assert!(!frame.graphics.windows(4).any(|bytes| bytes == b"a=p,")); - - server.app.state.workspaces[0].switch_tab(0); - server.render_and_stream(); - let _hidden_again = read_server_frame( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("frame after hiding pending upload"), - ); - server.start_direct_graphics_response(1, transfer_id, image_id); - assert!(server.complete_direct_graphics(1, transfer_id, image_id, true)); - assert!(serde_json::from_str::( - &response_rx.recv_timeout(Duration::from_secs(1)).unwrap() - ) - .is_ok()); - let slot = &server.app.pane_graphics.slots[&graphics_key(pane_id)]; - assert!(slot.stream_is_active()); - assert!(slot.layer.as_ref().unwrap().terminal_only()); - - server.app.state.workspaces[0].switch_tab(background_tab); - server.render_and_stream(); - let frame = read_server_frame( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("placement replay after tab switch"), - ); - let graphics = String::from_utf8_lossy(&frame.graphics); - assert!(graphics.contains("a=p,"), "{graphics:?}"); - assert!(graphics.contains(&format!("i={image_id}")), "{graphics:?}"); - assert!(!graphics.contains("a=t,"), "{graphics:?}"); - - let next_path = sparse_direct_frame( - &server, - "visible-next-frame.rgba", - image_width, - image_height, - ); - let (message, next_response_rx) = direct_stream_message( - "visible-frame", - &public_pane_id, - "browser", - next_path, - image_width, - image_height, - ); - assert_eq!( - server.handle_pane_graphics_stream_frame(message), - RenderImpact::None - ); - match read_server_message( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("next visible direct frame"), - ) { - ServerMessage::GraphicsFile { control, .. } => { - assert!(control.starts_with("a=T,"), "{control}"); - } - other => panic!("expected graphics file, got {other:?}"), - } - assert!(next_response_rx.try_recv().is_err()); - assert!(server.app.pane_graphics.slots[&graphics_key(pane_id)].stream_is_active()); -} - -#[cfg(unix)] -#[tokio::test] -async fn hidden_small_direct_frame_preserves_owned_inline_fallback() { - let (mut server, client_rx, _) = retained_test_server(b"active"); - enable_graphics_and_render(&mut server, &client_rx); - let background_tab = server.app.state.workspaces[0].test_add_tab(Some("browser")); - let pane_id = server.app.state.workspaces[0].tabs[background_tab].root_pane; - let pane_number = server.app.state.workspaces[0] - .public_pane_number(pane_id) - .unwrap(); - let public_pane_id = crate::workspace::public_pane_id_for_number( - &server.app.state.workspaces[0].id, - pane_number, - ); - server.clients.get_mut(&1).unwrap().direct_graphics = true; - server.app.direct_graphics_available = true; - set_stream_owner(&mut server, pane_id, "browser"); - - let path = sparse_direct_frame(&server, "hidden-small-frame.rgba", 1, 1); - let (message, response_rx) = - direct_stream_message("hidden-small", &public_pane_id, "browser", path, 1, 1); - assert_eq!( - server.handle_pane_graphics_stream_frame(message), - RenderImpact::Graphics - ); - assert!(serde_json::from_str::( - &response_rx.recv_timeout(Duration::from_secs(1)).unwrap() - ) - .is_ok()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - let slot = &server.app.pane_graphics.slots[&graphics_key(pane_id)]; - assert!(slot.stream_is_active()); - assert_eq!( - slot.layer.as_ref().unwrap().inline_data(), - Some([0; 4].as_slice()) - ); -} - -#[cfg(unix)] -#[tokio::test] -async fn direct_frame_during_internal_redraw_uploads_without_placement() { - let (mut server, client_rx, pane_id) = retained_test_server(b"active"); - enable_graphics_and_render(&mut server, &client_rx); - let pane_number = server.app.state.workspaces[0] - .public_pane_number(pane_id) - .unwrap(); - let public_pane_id = crate::workspace::public_pane_id_for_number( - &server.app.state.workspaces[0].id, - pane_number, - ); - server.clients.get_mut(&1).unwrap().direct_graphics = true; - server.app.direct_graphics_available = true; - set_stream_owner(&mut server, pane_id, "browser"); - server - .app - .event_tx - .try_send(AppEvent::UpdateReady { - version: "9.9.9".into(), - install_command: "herdr update".into(), - }) - .unwrap(); - - let image_width = 2_048; - let image_height = 2_049; - let path = sparse_direct_frame(&server, "redraw-frame.rgba", image_width, image_height); - let (message, response_rx) = direct_stream_message( - "redraw", - &public_pane_id, - "browser", - path, - image_width, - image_height, - ); - assert_eq!( - server.handle_pane_graphics_stream_frame(message), - RenderImpact::Full - ); - let (transfer_id, image_id) = match read_server_message( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("direct upload during redraw"), - ) { - ServerMessage::GraphicsFile { - control, - leading, - transfer_id, - image_id, - .. - } => { - assert!(leading.is_empty()); - assert!(control.starts_with("a=t,"), "{control}"); - (transfer_id, image_id) - } - other => panic!("expected graphics file, got {other:?}"), - }; - server.start_direct_graphics_response(1, transfer_id, image_id); - assert!(server.complete_direct_graphics(1, transfer_id, image_id, true)); - assert!(response_rx.recv_timeout(Duration::from_secs(1)).is_ok()); - assert!(server.app.pane_graphics.slots[&graphics_key(pane_id)].stream_is_active()); - - server.render_and_stream(); - let frame = read_server_frame( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("placement after redraw upload acknowledgement"), - ); - let graphics = String::from_utf8_lossy(&frame.graphics); - assert!(graphics.contains("a=p,"), "{graphics:?}"); - assert!(graphics.contains(&format!("i={image_id}")), "{graphics:?}"); - assert!(!graphics.contains("a=t,"), "{graphics:?}"); -} - #[cfg(unix)] fn direct_gate_server( data: &[u8], @@ -1324,8 +794,6 @@ fn add_direct_client(server: &mut HeadlessServer, client_id: u64) { width_px: 10, height_px: 20, }, - crate::terminal_theme::TerminalTheme::default(), - None, 1, RenderEncoding::SemanticFrame, Some(writer), @@ -1395,50 +863,6 @@ fn matching_terminal_ok_releases_producer_and_acknowledges() { assert!(layer.direct_lease().is_none()); } -#[cfg(unix)] -#[test] -fn explicit_terminal_error_acks_only_after_owned_inline_fallback() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - add_direct_client(&mut server, 7); - let (transfer_id, image_id) = direct_ids(&server, &key); - let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap(); - server - .clients - .get_mut(&7) - .unwrap() - .graphics_cache - .trust_pane_layer(&key, image_id, layer); - assert!(server.complete_direct_graphics(7, transfer_id, image_id, false)); - - let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap(); - assert_eq!( - ( - response_rx.recv().unwrap(), - layer.inline_data(), - server.clients[&7].direct_graphics, - server.clients[&7].pixel_mouse, - ), - ("ack".into(), Some([1, 2, 3, 4].as_slice()), false, true) - ); - assert!(server.clients[&7].graphics_cache.is_empty()); -} - -#[cfg(unix)] -#[test] -fn large_direct_terminal_error_closes_without_acknowledging_or_copying() { - let len = crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES + 4; - let (mut server, key, response_rx) = direct_gate_server_with_file(len, None); - add_direct_client(&mut server, 7); - let (transfer_id, image_id) = direct_ids(&server, &key); - - assert!(server.complete_direct_graphics(7, transfer_id, image_id, false)); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - assert!(matches!( - response_rx.try_recv(), - Err(std::sync::mpsc::TryRecvError::Disconnected) - )); -} - #[cfg(unix)] #[test] fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires() { @@ -1481,6 +905,43 @@ fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires() } } +#[cfg(unix)] +#[test] +fn eligibility_loss_cancels_the_queued_direct_upload() { + let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + let (writer, control_rx, _render_rx) = test_client_writer(); + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + 1, + RenderEncoding::SemanticFrame, + Some(writer), + ); + client.direct_graphics = true; + client.pixel_mouse = true; + server.clients.insert(7, client); + let (transfer_id, image_id) = direct_ids(&server, &key); + + server.retire_all_direct_graphics(); + + assert!(matches!( + read_server_message( + control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("direct retirement") + ), + ServerMessage::GraphicsTransmissionRetired { + transfer_id: retired_transfer, + image_id: retired_image, + } if retired_transfer == transfer_id && retired_image == image_id + )); + assert!(!server.app.pane_graphics.slots.contains_key(&key)); + assert!(response_rx.recv().is_err()); +} + #[cfg(unix)] #[test] fn client_loss_retires_only_its_direct_stream() { @@ -1506,6 +967,44 @@ fn client_loss_retires_only_its_direct_stream() { assert!(!resident.app.pane_graphics.slots.contains_key(&key)); } +#[cfg(unix)] +#[test] +fn pane_removal_cancels_the_pending_client_upload() { + let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + let (writer, control_rx, _render_rx) = test_client_writer(); + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + 1, + RenderEncoding::SemanticFrame, + Some(writer), + ); + client.direct_graphics = true; + client.pixel_mouse = true; + server.clients.insert(7, client); + let (transfer_id, image_id) = direct_ids(&server, &key); + server.app.state.workspaces.clear(); + + assert!(server.retain_live_pane_graphics()); + + assert!(matches!( + read_server_message( + control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("pane removal retirement") + ), + ServerMessage::GraphicsTransmissionRetired { + transfer_id: retired_transfer, + image_id: retired_image, + } if retired_transfer == transfer_id && retired_image == image_id + )); + assert!(!server.app.pane_graphics.slots.contains_key(&key)); + assert!(response_rx.recv().is_err()); +} + #[cfg(unix)] #[test] fn pane_removal_and_shutdown_drop_direct_without_ack() { diff --git a/src/server/pane_input.rs b/src/server/pane_input.rs index 9840c43c..4eb64cab 100644 --- a/src/server/pane_input.rs +++ b/src/server/pane_input.rs @@ -3,6 +3,106 @@ use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; use crate::protocol::{AttachScrollDirection, AttachScrollSource, ClientPaneInputEvent}; +pub(super) fn downgrade_ineligible_pixel_mouse( + events: &mut [ClientPaneInputEvent], + pixel_mouse: bool, + runtime_size: (u16, u16), + runtime_pixels: Option<(u32, u32)>, +) { + let (runtime_rows, runtime_cols) = runtime_size; + for event in events { + let ClientPaneInputEvent::Mouse { + position, geometry, .. + } = event + else { + continue; + }; + let crate::protocol::ClientMousePosition::Pixels { x, y, column, row } = *position else { + continue; + }; + let exact = pixel_mouse + && geometry.is_some_and(|geometry| { + (runtime_rows, runtime_cols) == (geometry.rows, geometry.cols) + && runtime_pixels == Some((geometry.width_px, geometry.height_px)) + && column < geometry.cols + && row < geometry.rows + && x > 0 + && y > 0 + && x <= geometry.width_px + && y <= geometry.height_px + }); + if !exact { + *position = crate::protocol::ClientMousePosition::Cell { column, row }; + *geometry = None; + } + } +} + +pub(super) fn terminal_attach_mouse_position( + runtime: &crate::terminal::TerminalRuntime, + terminal_size: (u16, u16), + cell_size: crate::kitty_graphics::HostCellSize, + pixel_mouse: bool, + host_sgr_pixels_active: bool, + position: crate::protocol::ClientMousePosition, + geometry: Option, +) -> Option { + let runtime_size = runtime.current_size(); + let cell_fallback = |column, row| { + (column < runtime_size.1 && row < runtime_size.0) + .then_some(crate::protocol::ClientMousePosition::Cell { column, row }) + }; + let (x, y, column, row) = match position { + crate::protocol::ClientMousePosition::Cell { column, row } => { + return cell_fallback(column, row); + } + crate::protocol::ClientMousePosition::Pixels { x, y, column, row } => (x, y, column, row), + }; + let Some(geometry) = geometry else { + return cell_fallback(column, row); + }; + let host_geometry = crate::input::mouse::HostGeometry::new( + geometry.cols, + geometry.rows, + geometry.width_px, + geometry.height_px, + )?; + if host_geometry.cell(x, y) != Some((column, row)) { + return None; + } + let exact = (|| { + let average_width = (geometry.width_px / u32::from(geometry.cols)).max(1); + let average_height = (geometry.height_px / u32::from(geometry.rows)).max(1); + let (child_width_px, child_height_px) = runtime.pixel_size()?; + if !pixel_mouse + || !host_sgr_pixels_active + || !runtime.sgr_pixel_mouse_enabled() + || terminal_size != (geometry.cols, geometry.rows) + || runtime_size != (geometry.rows, geometry.cols) + || !cell_size.is_known() + || average_width != cell_size.width_px + || average_height != cell_size.height_px + { + return None; + } + let crate::input::mouse::Position::Pixels { x, y } = (crate::input::mouse::HostPixels { + x, + y, + geometry: host_geometry, + }) + .pane_position( + ratatui::layout::Rect::new(0, 0, geometry.cols, geometry.rows), + child_width_px, + child_height_px, + )? + else { + return None; + }; + Some(crate::protocol::ClientMousePosition::Pixels { x, y, column, row }) + })(); + exact.or_else(|| cell_fallback(column, row)) +} + pub(super) fn apply_terminal_attach_scroll( runtime: &crate::terminal::TerminalRuntime, source: AttachScrollSource, @@ -125,6 +225,7 @@ fn apply_client_terminal_input_events( position, modifiers, lines, + .. } = event { let kind = kind.to_crossterm(); @@ -243,3 +344,172 @@ fn apply_client_terminal_input_events( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn terminal_attach_stale_geometry_falls_back_to_the_canonical_cell() { + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b""); + let position = crate::protocol::ClientMousePosition::Pixels { + x: 121, + y: 81, + column: 12, + row: 4, + }; + + assert_eq!( + terminal_attach_mouse_position( + &runtime, + (20, 5), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + true, + false, + position, + Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + ), + Some(crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }) + ); + assert_eq!( + terminal_attach_mouse_position( + &runtime, + (20, 5), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + true, + false, + crate::protocol::ClientMousePosition::Pixels { + x: 120, + y: 80, + column: 12, + row: 4, + }, + Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + ), + None + ); + assert_eq!( + terminal_attach_mouse_position( + &runtime, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + false, + false, + crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }, + None, + ), + Some(crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }) + ); + } + + #[test] + fn ineligible_shell_pixel_mouse_uses_its_canonical_cell_position() { + let mut events = vec![ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left), + position: crate::protocol::ClientMousePosition::Pixels { + x: 121, + y: 81, + column: 12, + row: 4, + }, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + modifiers: 0, + lines: 1, + }]; + + downgrade_ineligible_pixel_mouse(&mut events, false, (5, 20), Some((200, 100))); + + assert!(matches!( + events.as_slice(), + [ClientPaneInputEvent::Mouse { + position: crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }, + .. + }] + )); + } + + #[test] + fn eligible_shell_pixel_mouse_remains_exact() { + let position = crate::protocol::ClientMousePosition::Pixels { + x: 121, + y: 81, + column: 12, + row: 4, + }; + let mut events = vec![ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + modifiers: 0, + lines: 1, + }]; + + downgrade_ineligible_pixel_mouse(&mut events, true, (5, 20), Some((200, 100))); + + assert!(matches!( + events.as_slice(), + [ClientPaneInputEvent::Mouse { + position: current, + .. + }] if *current == position + )); + } + + #[test] + fn stale_shell_pixel_geometry_downgrades_to_its_canonical_cell() { + let mut events = vec![ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Pixels { + x: 121, + y: 81, + column: 12, + row: 4, + }, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + modifiers: 0, + lines: 1, + }]; + + downgrade_ineligible_pixel_mouse(&mut events, true, (6, 20), Some((200, 120))); + + assert!(matches!( + events.as_slice(), + [ClientPaneInputEvent::Mouse { + position: crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }, + geometry: None, + .. + }] + )); + } +} diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index b5eeb1fc..5ee9dd27 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -4,7 +4,6 @@ use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize}; use ratatui::layout::{Position, Rect, Size}; use crate::app::state::AppState; -use crate::app::Mode; use crate::protocol::render_ansi::{BlitEncoder, EncodedBlit}; use crate::protocol::{ ClientShellPopupSurface, CursorState, FrameData, PaneSurfaceFrame, PaneSurfacePane, @@ -101,41 +100,9 @@ impl ClientRenderState { } } - pub(crate) fn reset_semantic_input_baseline(&mut self) { - if let Self::Semantic { - last_frame, - last_surface_panes, - last_surface_popup, - last_surface_graphics_placements, - last_surface_graphics_retained, - last_surface_projection_revision, - } = self - { - *last_frame = None; - *last_surface_panes = None; - *last_surface_popup = None; - *last_surface_graphics_placements = None; - *last_surface_graphics_retained = None; - *last_surface_projection_revision = None; - } - } - pub(crate) fn prepare_frame(&mut self, frame: FrameData) -> Option { match self { - Self::Semantic { - last_frame, - last_surface_panes, - .. - } => { - if last_frame.as_ref() == Some(&frame) && last_surface_panes.is_none() { - crate::render_prof::event("prepare_frame.semantic.skip_current"); - return None; - } - crate::render_prof::event("prepare_frame.semantic.changed"); - Some(PreparedRender::Semantic { - message: ServerMessage::Frame(frame), - }) - } + Self::Semantic { .. } => None, Self::TerminalAnsi { blit_encoder, seq, @@ -203,35 +170,8 @@ impl ClientRenderState { }) } - pub(crate) fn last_frame(&self) -> Option<&FrameData> { - match self { - Self::Semantic { last_frame, .. } => last_frame.as_ref(), - Self::TerminalAnsi { blit_encoder, .. } => blit_encoder.last_frame(), - } - } - pub(crate) fn commit_sent_frame(&mut self, prepared: PreparedRender) { match (self, prepared) { - ( - Self::Semantic { - last_frame, - last_surface_panes, - last_surface_popup, - last_surface_graphics_placements, - last_surface_graphics_retained, - last_surface_projection_revision, - }, - PreparedRender::Semantic { - message: ServerMessage::Frame(frame), - }, - ) => { - *last_frame = Some(frame); - *last_surface_panes = None; - *last_surface_popup = None; - *last_surface_graphics_placements = None; - *last_surface_graphics_retained = None; - *last_surface_projection_revision = None; - } ( Self::Semantic { last_frame, @@ -271,56 +211,6 @@ impl ClientRenderState { _ => {} } } - - #[cfg(test)] - pub(crate) fn terminal_seq(&self) -> Option { - match self { - Self::Semantic { .. } => None, - Self::TerminalAnsi { seq, .. } => Some(*seq), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn popup_surface(content: &str) -> PaneSurfaceFrame { - let pane = ratatui::buffer::Buffer::with_lines(["pane"]); - let popup = ratatui::buffer::Buffer::with_lines([content]); - PaneSurfaceFrame { - boot_id: "boot-1".into(), - projection_revision: 1, - frame: FrameData::from_ratatui_buffer_with_hyperlinks(&pane, None, &[]), - panes: Vec::new(), - splits: Vec::new(), - popup: Some(Box::new(ClientShellPopupSurface { - terminal_id: "popup-terminal".into(), - title: "popup".into(), - width: None, - height: None, - frame: FrameData::from_ratatui_buffer_with_hyperlinks(&popup, None, &[]), - mouse_reporting: false, - sgr_pixel_mouse: false, - pixel_width: 0, - pixel_height: 0, - })), - graphics: crate::protocol::SurfaceGraphicsScene::default(), - } - } - - #[test] - fn popup_only_surface_changes_are_not_deduplicated() { - let mut state = ClientRenderState::new(RenderEncoding::SemanticFrame); - let prepared = state - .prepare_pane_surface(popup_surface("first")) - .expect("initial surface"); - state.commit_sent_frame(prepared); - - assert!(state - .prepare_pane_surface(popup_surface("second")) - .is_some()); - } } fn insert_graphics_before_sync_end(encoded: &mut Vec, graphics: &[u8]) { @@ -367,19 +257,6 @@ impl PreparedRender { surface.graphics.assets.clear(); true } - - pub(crate) fn into_frame(self) -> Option { - match self { - Self::Semantic { - message: ServerMessage::Frame(frame), - } => Some(frame), - Self::Semantic { - message: ServerMessage::PaneSurface(surface), - } => Some(surface.frame), - Self::TerminalAnsi { frame, .. } => Some(frame), - _ => None, - } - } } struct CursorTrackingBackend { @@ -465,90 +342,6 @@ impl Backend for CursorTrackingBackend { } } -/// Renders the AppState to an in-memory ratatui Buffer. -/// -/// This produces the legacy full-app surface in a `Buffer` instead of writing -/// to stdout. Cursor visibility is captured -/// from explicit frame cursor intent rather than incidental backend state. -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn render_virtual( - app_state: &mut AppState, - area: Rect, - resize_panes: bool, -) -> (ratatui::buffer::Buffer, Option) { - let terminal_runtimes = TerminalRuntimeRegistry::new(); - render_virtual_with_runtime_registry( - app_state, - &terminal_runtimes, - area, - resize_panes, - crate::kitty_graphics::HostCellSize::default(), - ) -} - -pub(crate) fn render_virtual_with_runtime_registry( - app_state: &mut AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - area: Rect, - resize_panes: bool, - cell_size: crate::kitty_graphics::HostCellSize, -) -> (ratatui::buffer::Buffer, Option) { - let popup_visible = app_state.popup_pane.is_some(); - let pre_compute_suppresses_focused_terminal_cursor = - !popup_visible && focused_terminal_suppresses_host_cursor(app_state, terminal_runtimes); - if resize_panes { - crate::ui::compute_view_with_cell_size(app_state, terminal_runtimes, area, cell_size); - } else { - crate::ui::compute_view_without_resizing_panes(app_state, terminal_runtimes, area); - } - let suppress_focused_terminal_cursor = pre_compute_suppresses_focused_terminal_cursor - || (!popup_visible - && focused_terminal_suppresses_host_cursor(app_state, terminal_runtimes)); - - let backend = CursorTrackingBackend::new(area.width, area.height); - let mut terminal = ratatui::Terminal::new(backend).expect("TestBackend::new should never fail"); - - terminal - .draw(|frame| { - crate::ui::render_with_runtime_registry(app_state, terminal_runtimes, frame); - }) - .expect("render to TestBackend should never fail"); - - let buffer = terminal.backend().buffer().clone(); - let cursor = if popup_visible { - popup_terminal_cursor(app_state, terminal_runtimes) - } else if suppress_focused_terminal_cursor { - None - } else { - focused_terminal_cursor(app_state, terminal_runtimes).or_else(|| { - (!focused_terminal_owns_host_cursor(app_state, terminal_runtimes)) - .then(|| terminal.backend().rendered_cursor()) - .flatten() - }) - }; - - (buffer, cursor) -} - -fn popup_terminal_cursor( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> Option { - let popup = app_state.popup_pane.as_ref()?; - let runtime = terminal_runtimes.get(&popup.terminal_id)?; - if runtime.synchronized_output_active() { - return None; - } - let (_, inner) = crate::ui::popup_pane_rects(app_state, app_state.view.terminal_area)?; - let cursor = runtime.cursor_state(inner, true)?; - Some(CursorState { - x: cursor.x, - y: cursor.y, - visible: cursor.visible && !crate::ui::pane_is_scrolled_back(runtime), - shape: cursor.shape, - }) -} - pub(crate) type RenderedTabSurface = ( ratatui::buffer::Buffer, Option, @@ -623,242 +416,44 @@ pub(crate) fn render_terminal_virtual( (buffer, cursor) } -pub(crate) fn visible_hyperlinks( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> Vec<((u16, u16), String, String)> { - crate::ui::tab_surface_hyperlinks(app_state, terminal_runtimes, app_state.view.tab_surface()) -} - -pub(crate) fn focused_terminal_cursor( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> Option { - crate::ui::tab_surface_cursor(app_state, terminal_runtimes, app_state.view.tab_surface()) -} - -fn focused_terminal_owns_host_cursor( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> bool { - if app_state.mode != Mode::Terminal { - return false; - } - - let Some(ws_idx) = app_state.active else { - return false; - }; - let Some(info) = app_state - .view - .pane_infos - .iter() - .find(|info| info.is_focused) - else { - return false; - }; - if !app_state.pane_exposes_host_cursor(ws_idx, info.id) { - return false; - } - - app_state - .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - .is_some() -} - -fn focused_terminal_suppresses_host_cursor( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> bool { - if app_state.mode != Mode::Terminal { - return false; - } - - let Some(ws_idx) = app_state.active else { - return false; - }; - let Some(info) = app_state - .view - .pane_infos - .iter() - .find(|info| info.is_focused) - else { - return false; - }; - if !app_state.pane_exposes_host_cursor(ws_idx, info.id) { - return false; - } - - app_state - .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - .is_some_and(crate::terminal::TerminalRuntime::synchronized_output_active) -} - #[cfg(test)] -mod render_scale_benchmark { - use std::hint::black_box; - use std::time::Instant; - - use ratatui::layout::Direction; - +mod tests { use super::*; - use crate::app::Mode; - use crate::terminal::TerminalRuntime; - use crate::workspace::Workspace; - const AREA: Rect = Rect::new(0, 0, 120, 40); - const SAMPLE_COUNT: usize = 40; - const WARMUP_COUNT: usize = 5; - - #[derive(Clone, Copy)] - struct RenderStats { - median_us: u128, - p95_us: u128, - max_us: u128, - } - - fn history() -> String { - (0..2_000).map(|line| format!("line-{line}\r\n")).collect() - } - - fn runtime(history: &str) -> TerminalRuntime { - TerminalRuntime::test_with_scrollback_bytes( - AREA.width, - AREA.height, - 1024 * 1024, - history.as_bytes(), - ) - } - - fn app_with_workspaces(workspace_count: usize) -> AppState { - let history = history(); - let workspaces = (0..workspace_count) - .map(|index| { - let mut workspace = Workspace::test_new(&format!("bench-{}", index + 1)); - let root_pane = workspace.tabs[0].root_pane; - workspace.tabs[0] - .runtimes - .insert(root_pane, runtime(&history)); - workspace - }) - .collect(); - app_with(workspaces) - } - - fn app_with_active_panes(pane_count: usize) -> AppState { - let history = history(); - let mut workspace = Workspace::test_new("bench"); - let root_pane = workspace.tabs[0].root_pane; - workspace.tabs[0] - .runtimes - .insert(root_pane, runtime(&history)); - let mut pane_ids = vec![root_pane]; - - for index in 1..pane_count { - let target = pane_ids[(index - 1) / 2]; - workspace.tabs[0].layout.focus_pane(target); - let direction = if index % 2 == 0 { - Direction::Vertical - } else { - Direction::Horizontal - }; - let pane_id = workspace.test_split(direction); - workspace.tabs[0] - .runtimes - .insert(pane_id, runtime(&history)); - pane_ids.push(pane_id); - } - - app_with(vec![workspace]) - } - - fn app_with(workspaces: Vec) -> AppState { - let mut app = AppState::test_new(); - app.mode = Mode::Terminal; - app.pane_scrollbars = true; - app.workspaces = workspaces; - app.active = Some(0); - app.selected = 0; - app - } - - fn profile(mut app: AppState) -> RenderStats { - for _ in 0..WARMUP_COUNT { - black_box(render_virtual(&mut app, AREA, true)); - } - - let mut samples = Vec::with_capacity(SAMPLE_COUNT); - for _ in 0..SAMPLE_COUNT { - let started = Instant::now(); - black_box(render_virtual(&mut app, AREA, true)); - samples.push(started.elapsed().as_micros()); - } - samples.sort_unstable(); - - RenderStats { - median_us: samples[SAMPLE_COUNT / 2], - p95_us: samples[(SAMPLE_COUNT - 1) * 95 / 100], - max_us: samples[SAMPLE_COUNT - 1], + fn popup_surface(content: &str) -> PaneSurfaceFrame { + let pane = ratatui::buffer::Buffer::with_lines(["pane"]); + let popup = ratatui::buffer::Buffer::with_lines([content]); + PaneSurfaceFrame { + boot_id: "boot-1".into(), + projection_revision: 1, + frame: FrameData::from_ratatui_buffer_with_hyperlinks(&pane, None, &[]), + panes: Vec::new(), + splits: Vec::new(), + popup: Some(Box::new(ClientShellPopupSurface { + terminal_id: "popup-terminal".into(), + title: "popup".into(), + width: None, + height: None, + frame: FrameData::from_ratatui_buffer_with_hyperlinks(&popup, None, &[]), + mouse_reporting: false, + sgr_pixel_mouse: false, + pixel_width: 0, + pixel_height: 0, + })), + graphics: crate::protocol::SurfaceGraphicsScene::default(), } } - fn profile_cardinalities(build: fn(usize) -> AppState) -> [(usize, RenderStats); 3] { - [1, 15, 50].map(|count| (count, profile(build(count)))) - } + #[test] + fn popup_only_surface_changes_are_not_deduplicated() { + let mut state = ClientRenderState::new(RenderEncoding::SemanticFrame); + let prepared = state + .prepare_pane_surface(popup_surface("first")) + .expect("initial surface"); + state.commit_sent_frame(prepared); - fn print_profiles(label: &str, profiles: [(usize, RenderStats); 3]) { - let baseline_median_us = profiles[0].1.median_us as f64; - let baseline_p95_us = profiles[0].1.p95_us as f64; - println!("{label}"); - println!(" count median_us p95_us max_us median_vs_1x p95_vs_1x"); - for (count, stats) in profiles { - println!( - "{count:>10} {:>9} {:>6} {:>6} {:>12.2} {:>9.2}", - stats.median_us, - stats.p95_us, - stats.max_us, - stats.median_us as f64 / baseline_median_us, - stats.p95_us as f64 / baseline_p95_us, - ); - } - } - - fn assert_full_render_avoids_aggregate_input_state(mut app: AppState, scenario: &str) { - crate::pane::reset_aggregate_input_state_reads(); - black_box(render_virtual(&mut app, AREA, true)); - assert_eq!( - crate::pane::aggregate_input_state_reads(), - 0, - "full render collected aggregate input state for {scenario}", - ); - } - - #[tokio::test(flavor = "current_thread")] - async fn aggregate_input_state_counter_records_reads() { - let runtime = TerminalRuntime::test_with_screen_bytes(80, 24, b""); - crate::pane::reset_aggregate_input_state_reads(); - black_box(runtime.input_state()); - assert_eq!(crate::pane::aggregate_input_state_reads(), 1); - } - - #[tokio::test(flavor = "current_thread")] - async fn full_render_avoids_aggregate_input_state_reads() { - assert_full_render_avoids_aggregate_input_state( - app_with_workspaces(15), - "background workspaces", - ); - assert_full_render_avoids_aggregate_input_state(app_with_active_panes(15), "active panes"); - } - - #[tokio::test(flavor = "current_thread")] - #[ignore = "manual full-render scaling profile"] - async fn render_scale_profile() { - print_profiles( - "background-workspace resize/layout (one pane each)", - profile_cardinalities(app_with_workspaces), - ); - print_profiles( - "active panes (one workspace)", - profile_cardinalities(app_with_active_panes), - ); + assert!(state + .prepare_pane_surface(popup_surface("second")) + .is_some()); } } diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 901dd686..5817a7e2 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -245,11 +245,6 @@ impl TerminalRuntime { self.0.agent_detection_reset_notify_for_test() } - #[cfg(test)] - pub(crate) fn agent_detection_enabled_for_test(&self) -> bool { - self.0.agent_detection_enabled_for_test() - } - pub fn set_full_lifecycle_authority_active(&self, active: bool) { self.0.set_full_lifecycle_authority_active(active); } @@ -283,14 +278,6 @@ impl TerminalRuntime { self.0.scroll_metrics() } - pub(crate) fn search_text_matches( - &self, - query: &str, - case_sensitive: bool, - ) -> Vec { - self.0.search_text_matches(query, case_sensitive) - } - pub(crate) fn search_text_window( &self, query: &str, @@ -307,17 +294,6 @@ impl TerminalRuntime { .search_text_window(query, case_sensitive, direction, cursor, previous, limit) } - pub(crate) fn text_match_is_current(&self, text_match: crate::pane::TerminalTextMatch) -> bool { - self.0.text_match_is_current(text_match) - } - - pub(crate) fn text_matches_are_current( - &self, - text_matches: &[crate::pane::TerminalTextMatch], - ) -> Vec { - self.0.text_matches_are_current(text_matches) - } - pub(crate) fn word_motion_target( &self, row: u32, @@ -339,19 +315,6 @@ impl TerminalRuntime { self.0.paragraph_motion_target(row, direction) } - /// Collects the complete terminal input-mode snapshot. - /// - /// This performs multiple terminal queries. Keep it out of render/layout - /// and pane-scaled loops; add a narrow accessor when one fact is needed. - #[cfg(test)] - pub fn input_state(&self) -> Option { - self.0.input_state() - } - - pub fn keyboard_report_all_requested(&self) -> bool { - self.0.keyboard_report_all_requested() - } - pub fn bracketed_paste_enabled(&self) -> bool { self.0.bracketed_paste_enabled() } @@ -448,14 +411,6 @@ impl TerminalRuntime { self.0.render(frame, area, show_cursor); } - pub(crate) fn collect_dirty_patch( - &self, - area_width: u16, - area_height: u16, - ) -> crate::pane::TerminalDirtyPatchOutcome { - self.0.collect_dirty_patch(area_width, area_height) - } - pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> { self.0.visible_hyperlinks(area) } @@ -474,6 +429,10 @@ impl TerminalRuntime { self.0.keyboard_protocol() } + pub fn modify_other_keys_level(&self) -> u8 { + self.0.modify_other_keys_level() + } + pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { self.0.encode_terminal_key(key) } diff --git a/src/terminal_modes.rs b/src/terminal_modes.rs index c9f08ceb..b38d251b 100644 --- a/src/terminal_modes.rs +++ b/src/terminal_modes.rs @@ -1,7 +1,5 @@ use std::io::{self, Write}; -#[cfg(not(windows))] -use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}; #[cfg(any(not(windows), test))] const DISABLE_HOST_MOUSE_REPORTING_SEQUENCE: &[u8] = b"\x1b[?1006l\x1b[?1016l\x1b[?1015l\x1b[?1005l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; @@ -53,18 +51,14 @@ pub(crate) fn set_host_kitty_keyboard_report_all( let mut flags = crate::input::ime_compatible_keyboard_enhancement_flags(); if report_all_keys { flags |= crossterm::event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES; - // Report-all turns IME commits into CSI-u key events in terminals such - // as Ghostty. Ask the terminal to carry the committed text with them. flags = crossterm::event::KeyboardEnhancementFlags::from_bits_retain( flags.bits() | 0b0001_0000, ); } - // Older iTerm2 releases clear the keyboard stack on SET, so a later pop - // cannot restore the host state. Replace only Herdr's top entry instead. crossterm::execute!( writer, - PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags(flags) + crossterm::event::PopKeyboardEnhancementFlags, + crossterm::event::PushKeyboardEnhancementFlags(flags) ) } @@ -76,10 +70,64 @@ pub(crate) fn set_host_kitty_keyboard_report_all( Ok(()) } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DirectHostKeyboardState { + kitty_flags: Option, + modify_other_keys_level: u8, +} + +#[cfg(not(windows))] +pub(crate) fn set_direct_host_keyboard_protocol( + writer: &mut W, + active: &mut DirectHostKeyboardState, + next_flags: u16, + next_modify_other_keys_level: u8, +) -> io::Result<()> { + let next_kitty_flags = (next_flags != 0).then_some(next_flags); + if active.kitty_flags == next_kitty_flags + && active.modify_other_keys_level == next_modify_other_keys_level + { + return Ok(()); + } + + if active.kitty_flags != next_kitty_flags { + if active.kitty_flags.is_some() { + writer.write_all(b"\x1b[<1u")?; + } + if next_flags != 0 { + write!(writer, "\x1b[>{next_flags}u")?; + } + } + if active.modify_other_keys_level != next_modify_other_keys_level { + write!(writer, "\x1b[>4;{next_modify_other_keys_level}m")?; + } + writer.flush()?; + *active = DirectHostKeyboardState { + kitty_flags: next_kitty_flags, + modify_other_keys_level: next_modify_other_keys_level, + }; + Ok(()) +} + +#[cfg(windows)] +pub(crate) fn set_direct_host_keyboard_protocol( + _writer: &mut W, + active: &mut DirectHostKeyboardState, + next_flags: u16, + next_modify_other_keys_level: u8, +) -> io::Result<()> { + *active = DirectHostKeyboardState { + kitty_flags: (next_flags != 0).then_some(next_flags), + modify_other_keys_level: next_modify_other_keys_level, + }; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; + #[cfg(not(windows))] #[test] fn host_keyboard_report_all_replaces_the_current_herdr_stack_entry() { let mut output = Vec::new(); @@ -90,6 +138,48 @@ mod tests { assert_eq!(output, b"\x1b[<1u\x1b[>31u\x1b[<1u\x1b[>7u"); } + #[cfg(not(windows))] + #[test] + fn direct_keyboard_protocol_owns_exactly_one_stack_entry_and_modify_other_keys() { + let mut output = Vec::new(); + let mut active = DirectHostKeyboardState::default(); + + set_direct_host_keyboard_protocol(&mut output, &mut active, 3, 0).unwrap(); + set_direct_host_keyboard_protocol(&mut output, &mut active, 15, 2).unwrap(); + set_direct_host_keyboard_protocol(&mut output, &mut active, 0, 0).unwrap(); + + assert_eq!( + output, + b"\x1b[>3u\x1b[<1u\x1b[>15u\x1b[>4;2m\x1b[<1u\x1b[>4;0m" + ); + assert_eq!(active, DirectHostKeyboardState::default()); + } + + #[cfg(not(windows))] + #[test] + fn direct_modify_other_keys_works_without_kitty_flags() { + let mut output = Vec::new(); + let mut active = DirectHostKeyboardState::default(); + + set_direct_host_keyboard_protocol(&mut output, &mut active, 0, 1).unwrap(); + set_direct_host_keyboard_protocol(&mut output, &mut active, 0, 2).unwrap(); + set_direct_host_keyboard_protocol(&mut output, &mut active, 0, 0).unwrap(); + + assert_eq!(output, b"\x1b[>4;1m\x1b[>4;2m\x1b[>4;0m"); + assert_eq!(active, DirectHostKeyboardState::default()); + } + + #[test] + fn direct_legacy_keyboard_mode_does_not_pop_the_host_stack() { + let mut output = Vec::new(); + let mut active = DirectHostKeyboardState::default(); + + set_direct_host_keyboard_protocol(&mut output, &mut active, 0, 0).unwrap(); + + assert!(output.is_empty()); + assert_eq!(active, DirectHostKeyboardState::default()); + } + #[test] fn clears_all_known_host_mouse_modes() { let sequence = std::str::from_utf8(DISABLE_HOST_MOUSE_REPORTING_SEQUENCE).unwrap(); diff --git a/src/ui.rs b/src/ui.rs index 13af355c..31a4ef6a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,127 +1,52 @@ -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::Span, - Frame, -}; +use ratatui::layout::Rect; -mod dialogs; -mod keybind_help; -mod menus; -mod mobile; -mod navigator; mod onboarding; mod panes; mod release_notes; mod scrollbar; -mod settings; mod sidebar; mod status; mod tab_surface; -mod tabs; mod text; -pub(crate) use text::truncate_end; mod widgets; -use self::dialogs::{ - render_confirm_close_overlay, render_new_linked_worktree_overlay, - render_open_existing_worktree_overlay, render_remove_worktree_overlay, render_rename_overlay, -}; -use self::keybind_help::render_keybind_help_overlay; -use self::menus::{ - render_context_menu, render_copy_mode_overlay, render_global_launcher_menu, - render_navigate_overlay, render_prefix_overlay, render_resize_overlay, -}; -use self::mobile::{ - compute_mobile_header_hit_areas, is_mobile_width, mobile_switcher_max_scroll_for_height, - mobile_toast_banner_rect, render_mobile_header, render_mobile_panel, - render_mobile_toast_banner, -}; -use self::navigator::render_navigator_overlay; -use self::onboarding::render_onboarding_overlay; pub(crate) use self::onboarding::{ onboarding_welcome_continue_rect, ONBOARDING_DESCRIPTION, ONBOARDING_HELP_LABEL, ONBOARDING_HELP_SUFFIX, ONBOARDING_NEXT, ONBOARDING_PREFIX_LABEL, ONBOARDING_PREFIX_SUFFIX, ONBOARDING_SUBTITLE, ONBOARDING_TITLE, }; -pub(crate) use self::panes::{popup_pane_rects, render_selection_highlight}; -use self::panes::{render_empty, render_popup_pane, resize_popup_pane}; +#[cfg(test)] +pub(crate) use self::panes::popup_pane_rects; +use self::panes::resize_popup_pane; +pub(crate) use self::panes::{ + apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back, render_selection_highlight, +}; pub(crate) use self::release_notes::{ product_announcement_display_lines, product_announcement_scroll_metrics, release_notes_close_button_rect, release_notes_display_lines, release_notes_scroll_metrics, PRODUCT_ANNOUNCEMENT_MODAL_SIZE, RELEASE_NOTES_MODAL_SIZE, }; -use self::release_notes::{render_product_announcement_overlay, render_release_notes_overlay}; pub(crate) use self::scrollbar::{ - pane_scrollbar_rect, release_notes_scrollbar_rect, render_scrollbar_buffer, - scrollbar_offset_from_drag_row, scrollbar_offset_from_row, scrollbar_thumb, - scrollbar_thumb_grab_offset, should_show_scrollbar, + release_notes_scrollbar_rect, render_scrollbar_buffer, scrollbar_offset_from_drag_row, + scrollbar_offset_from_row, scrollbar_thumb, scrollbar_thumb_grab_offset, }; -use self::settings::render_settings_overlay; -pub(crate) use self::sidebar::agent_panel_entries_from; -#[cfg(test)] -pub(crate) use self::sidebar::workspace_drop_indicator_row; -use self::sidebar::{render_sidebar, render_sidebar_collapsed}; -use self::status::{ - copy_feedback_rect, render_config_diagnostic, render_copy_feedback, render_toast_notification, - toast_notification_rect, +pub(crate) use self::sidebar::{ + agent_panel_entries_from, expanded_sidebar_sections, resolved_token_spans, sidebar_agent_rows, + sidebar_section_divider_rect, sidebar_space_rows, AgentPanelEntry, AgentTokenContext, + ResolvedToken, ResolvedTokenKind, SpaceTokenContext, }; +use self::status::copy_feedback_rect; pub(crate) use self::status::{render_config_diagnostic_buffer, render_copy_feedback_buffer}; pub(crate) use self::tab_surface::{ - compute_tab_surface, render_tab_surface, resize_tab_surface, TabSurfaceLayout, -}; -use self::tabs::render_tab_bar; -pub(crate) use self::{ - dialogs::{ - confirm_close_button_rects, confirm_close_popup_rect, new_linked_worktree_button_rects, - new_linked_worktree_inner_rect, open_existing_worktree_button_rects, - open_existing_worktree_inner_rect, open_existing_worktree_max_visible_rows, - open_existing_worktree_visible_start, remove_worktree_button_rects, - remove_worktree_popup_rect, rename_button_rects, - }, - settings::{ - settings_button_rects, settings_popup_height, settings_show_primary_action, - SETTINGS_POPUP_WIDTH, - }, - sidebar::{ - agent_entry_gap, agent_entry_height_in_body, agent_panel_body_rect, agent_panel_entries, - agent_panel_scroll_for_target, agent_panel_scroll_metrics, agent_panel_scrollbar_rect, - agent_panel_toggle_rect, all_agent_panel_entries, collapsed_sidebar_sections, - collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections, - expanded_sidebar_toggle_rect, normalized_workspace_scroll, resolved_token_spans, - sidebar_agent_rows, sidebar_section_divider_rect, sidebar_space_rows, workspace_drop_slots, - workspace_group_chevron_rect, workspace_list_entries, workspace_list_entries_expanded, - workspace_list_rect, workspace_list_scroll_metrics, workspace_list_scrollbar_rect, - workspace_parent_group_state, AgentPanelEntry, AgentTokenContext, ResolvedToken, - ResolvedTokenKind, SpaceTokenContext, WorkspaceListEntry, - }, + compute_tab_surface, render_tab_surface, resize_tab_surface, tab_surface_cursor, + tab_surface_hyperlinks, TabSurfaceLayout, TabSurfaceView, }; +pub(crate) use self::text::truncate_end; +pub(crate) use self::widgets::{centered_popup_rect, modal_stack_areas}; -pub(crate) use self::{ - keybind_help::keybind_help_lines, - mobile::{ - mobile_switcher_areas, mobile_switcher_max_scroll, mobile_switcher_target_at, - mobile_switcher_workspace_doc_range, MobileSwitcherTarget, - }, - panes::{apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back}, - tab_surface::{tab_surface_cursor, tab_surface_hyperlinks, TabSurfaceView}, - tabs::{compute_tab_bar_view, tab_bar_content_area}, - widgets::{centered_popup_rect, modal_stack_areas}, -}; -use crate::app::state::ViewLayout; -use crate::app::{AppState, Mode}; +use crate::app::AppState; use crate::terminal::TerminalRuntimeRegistry; -const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator - -/// Compute view geometry and reconcile pane sizes. -/// Called before render to separate mutation from drawing. -#[cfg_attr(not(test), allow(dead_code))] -pub fn compute_view(app: &mut AppState, area: Rect) { - let terminal_runtimes = TerminalRuntimeRegistry::new(); - compute_view_with_runtime_registry(app, &terminal_runtimes, area); -} - pub fn compute_view_with_runtime_registry( app: &mut AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -145,11 +70,6 @@ pub fn compute_view_with_cell_size( compute_view_internal(app, terminal_runtimes, area, true, cell_size); } -/// Compute view geometry for a client-sized render without resizing pane runtimes. -/// -/// This is used by the headless server when a non-foreground client needs its -/// own frame size while the shared pane runtimes stay pinned to the foreground -/// client. pub(crate) fn compute_view_without_resizing_panes( app: &mut AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -164,63 +84,6 @@ pub(crate) fn compute_view_without_resizing_panes( ); } -fn resize_background_tab_panes_to_area( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - terminal_area: Rect, - cell_size: crate::kitty_graphics::HostCellSize, -) { - for (ws_idx, ws) in app.workspaces.iter().enumerate() { - for (tab_idx, tab) in ws.tabs.iter().enumerate() { - if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() { - continue; - } - resize_tab_surface(app, terminal_runtimes, tab, terminal_area, cell_size); - } - } -} - -fn resize_background_tab_panes_for_desktop( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - main_area: Rect, - cell_size: crate::kitty_graphics::HostCellSize, -) { - for (ws_idx, ws) in app.workspaces.iter().enumerate() { - let (_, terminal_area) = desktop_tab_bar_and_terminal_area(app, ws, main_area); - for (tab_idx, tab) in ws.tabs.iter().enumerate() { - if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() { - continue; - } - resize_tab_surface(app, terminal_runtimes, tab, terminal_area, cell_size); - } - } -} - -fn desktop_tab_bar_and_terminal_area( - app: &AppState, - ws: &crate::workspace::Workspace, - main_area: Rect, -) -> (Rect, Rect) { - let hide_single_tab_bar = app.hide_tab_bar_when_single_tab && ws.tabs.len() == 1; - if !hide_single_tab_bar && main_area.height > 1 { - match app.tab_bar_position { - crate::config::TabBarPositionConfig::Top => { - let [tab_bar_rect, terminal_area] = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area); - (tab_bar_rect, terminal_area) - } - crate::config::TabBarPositionConfig::Bottom => { - let [terminal_area, tab_bar_rect] = - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(main_area); - (tab_bar_rect, terminal_area) - } - } - } else { - (Rect::default(), main_area) - } -} - fn compute_view_internal( app: &mut AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -228,331 +91,33 @@ fn compute_view_internal( resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) { - if is_mobile_width(area, app.mobile_width_threshold) { - compute_mobile_view(app, terminal_runtimes, area, resize_panes, cell_size); - return; - } + let TabSurfaceLayout { pane_infos, .. } = + compute_tab_surface(app, terminal_runtimes, area, resize_panes, cell_size); - let sidebar_w = if app.sidebar_collapsed { - match app.sidebar_collapsed_mode { - crate::config::SidebarCollapsedModeConfig::Compact => COLLAPSED_WIDTH, - crate::config::SidebarCollapsedModeConfig::Hidden => 0, - } - } else { - app.sidebar_width - .clamp(app.sidebar_min_width, app.sidebar_max_width) - }; - - let [sidebar_area, main_area] = - Layout::horizontal([Constraint::Length(sidebar_w), Constraint::Min(1)]).areas(area); - - let (tab_bar_rect, terminal_area) = app - .active - .and_then(|i| app.workspaces.get(i)) - .map(|ws| desktop_tab_bar_and_terminal_area(app, ws, main_area)) - .unwrap_or((Rect::default(), main_area)); - - if !app.sidebar_collapsed { - app.workspace_scroll = normalized_workspace_scroll(app, sidebar_area, app.workspace_scroll); - let (_, detail_area) = expanded_sidebar_sections(sidebar_area, app.sidebar_section_split); - let max_agent_scroll = agent_panel_scroll_metrics(app, detail_area).max_offset_from_bottom; - app.agent_panel_scroll = app.agent_panel_scroll.min(max_agent_scroll); - } else { - app.workspace_scroll = app - .workspace_scroll - .min(app.workspaces.len().saturating_sub(1)); - app.agent_panel_scroll = 0; - } - - let workspace_card_areas = if app.sidebar_collapsed { - Vec::new() - } else { - compute_workspace_card_areas(app, sidebar_area) - }; - - let tab_bar_view = app - .active - .and_then(|ws_idx| app.workspaces.get(ws_idx)) - .map(|ws| { - compute_tab_bar_view( - ws, - tab_bar_content_area(app, tab_bar_rect), - app.tab_scroll, - app.tab_scroll_follow_active, - app.mouse_capture, - ) - }) - .unwrap_or_default(); - app.tab_scroll = tab_bar_view.scroll; - - let TabSurfaceLayout { - pane_infos, - split_borders, - } = compute_tab_surface( - app, - terminal_runtimes, - terminal_area, - resize_panes, - cell_size, - ); if resize_panes { - resize_background_tab_panes_for_desktop(app, terminal_runtimes, main_area, cell_size); - resize_popup_pane(app, terminal_runtimes, terminal_area, cell_size); + resize_background_tab_panes(app, terminal_runtimes, area, cell_size); + resize_popup_pane(app, terminal_runtimes, area, cell_size); } - let toast_hit_area = app - .toast - .as_ref() - .map(|toast| { - toast_notification_rect( - area, - toast, - app.config_diagnostic.is_some(), - toast.position.unwrap_or(app.toast_config.herdr.position), - ) - }) - .unwrap_or_default(); - app.view = crate::app::ViewState { - layout: ViewLayout::Desktop, - sidebar_rect: sidebar_area, - workspace_card_areas, - tab_bar_rect, - tab_hit_areas: tab_bar_view.tab_hit_areas, - tab_scroll_left_hit_area: tab_bar_view.scroll_left_hit_area, - tab_scroll_right_hit_area: tab_bar_view.scroll_right_hit_area, - new_tab_hit_area: tab_bar_view.new_tab_hit_area, - terminal_area, - mobile_header_rect: Rect::default(), - mobile_menu_hit_area: Rect::default(), - toast_hit_area, + terminal_area: area, pane_infos, - split_borders, }; - app.sync_copy_mode_search_geometry(); } -fn compute_mobile_view( - app: &mut AppState, +fn resize_background_tab_panes( + app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, area: Rect, - resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) { - let header_h = area.height.min(2); - let (header_rect, terminal_area) = if area.height > header_h { - let [header_rect, terminal_area] = - Layout::vertical([Constraint::Length(header_h), Constraint::Min(1)]).areas(area); - (header_rect, terminal_area) - } else { - (area, Rect::default()) - }; - - if app.mode == Mode::Navigate { - let switcher_viewport_h = area.height.saturating_sub(header_h + 1); - let max_scroll = mobile_switcher_max_scroll_for_height(app, switcher_viewport_h); - app.mobile_switcher_scroll = app.mobile_switcher_scroll.min(max_scroll); - } - - let TabSurfaceLayout { - pane_infos, - split_borders, - } = compute_tab_surface( - app, - terminal_runtimes, - terminal_area, - resize_panes, - cell_size, - ); - if resize_panes { - resize_background_tab_panes_to_area(app, terminal_runtimes, terminal_area, cell_size); - resize_popup_pane(app, terminal_runtimes, terminal_area, cell_size); - } - let header_hits = compute_mobile_header_hit_areas(app, header_rect); - - let toast_hit_area = app - .toast - .as_ref() - .map(|_| mobile_toast_banner_rect(area, app.config_diagnostic.is_some())) - .unwrap_or_default(); - - app.view = crate::app::ViewState { - layout: ViewLayout::Mobile, - sidebar_rect: Rect::default(), - workspace_card_areas: Vec::new(), - tab_bar_rect: Rect::default(), - tab_hit_areas: Vec::new(), - tab_scroll_left_hit_area: Rect::default(), - tab_scroll_right_hit_area: Rect::default(), - new_tab_hit_area: Rect::default(), - terminal_area, - mobile_header_rect: header_rect, - mobile_menu_hit_area: header_hits.menu, - toast_hit_area, - pane_infos, - split_borders, - }; - app.sync_copy_mode_search_geometry(); -} - -/// Render the UI — reads AppState but does not mutate it. -#[cfg_attr(not(test), allow(dead_code))] -pub fn render(app: &AppState, frame: &mut Frame) { - let terminal_runtimes = TerminalRuntimeRegistry::new(); - render_with_runtime_registry(app, &terminal_runtimes, frame); -} - -pub fn render_with_runtime_registry( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, -) { - let tab_bar_area = app.view.tab_bar_rect; - let terminal_area = app.view.terminal_area; - - render_navigation_chrome(app, terminal_runtimes, frame); - if app.view.layout != ViewLayout::Mobile { - render_tab_bar(app, frame, tab_bar_area); - } - if app - .active - .and_then(|ws_idx| app.workspaces.get(ws_idx)) - .is_some() - { - render_tab_surface(app, terminal_runtimes, app.view.tab_surface(), frame); - } else { - render_empty(app, frame, terminal_area); - } - - // Ambient notifications sit above panes, but below interactive overlays. - render_notifications(app, frame, terminal_area); - render_popup_pane(app, terminal_runtimes, frame, terminal_area); - - let mode_bar_area = if app.view.layout == ViewLayout::Desktop - && app.tab_bar_position == crate::config::TabBarPositionConfig::Bottom - && tab_bar_area.height > 0 - { - tab_bar_area - } else { - terminal_area - }; - - match app.mode { - Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()), - Mode::ReleaseNotes => render_release_notes_overlay(app, frame, frame.area()), - Mode::ProductAnnouncement => render_product_announcement_overlay(app, frame, frame.area()), - Mode::Navigate if app.view.layout == ViewLayout::Mobile => { - render_mobile_panel(app, terminal_runtimes, frame, frame.area()) + for (workspace_index, workspace) in app.workspaces.iter().enumerate() { + for (tab_index, tab) in workspace.tabs.iter().enumerate() { + if app.active == Some(workspace_index) && tab_index == workspace.active_tab_index() { + continue; + } + resize_tab_surface(app, terminal_runtimes, tab, area, cell_size); } - Mode::Navigate => render_navigate_overlay(app, frame, mode_bar_area), - Mode::Prefix => render_prefix_overlay(app, frame, mode_bar_area), - Mode::Copy => render_copy_mode_overlay(app, frame, mode_bar_area), - Mode::Resize => render_resize_overlay(app, frame, mode_bar_area), - Mode::ConfirmClose => { - render_confirm_close_overlay(app, terminal_runtimes, frame, terminal_area) - } - Mode::ContextMenu => { - render_context_menu(app, frame); - } - Mode::Settings => render_settings_overlay(app, frame, frame.area()), - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { - render_rename_overlay(app, frame, frame.area()) - } - Mode::NewLinkedWorktree => render_new_linked_worktree_overlay(app, frame, frame.area()), - Mode::OpenExistingWorktree => { - render_open_existing_worktree_overlay(app, frame, frame.area()) - } - Mode::ConfirmRemoveWorktree => render_remove_worktree_overlay(app, frame, frame.area()), - Mode::GlobalMenu => render_global_launcher_menu(app, frame), - Mode::KeybindHelp => render_keybind_help_overlay(app, frame), - Mode::Navigator => render_navigator_overlay(app, terminal_runtimes, frame), - Mode::Terminal => {} - } -} - -fn render_navigation_chrome( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, -) { - if app.view.layout == ViewLayout::Mobile { - render_mobile_header(app, terminal_runtimes, frame, app.view.mobile_header_rect); - } else if app.view.sidebar_rect.width > 0 { - if app.sidebar_collapsed { - render_sidebar_collapsed(app, frame, app.view.sidebar_rect); - } else { - render_sidebar(app, terminal_runtimes, frame, app.view.sidebar_rect); - } - } -} - -fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect) { - let has_config_diagnostic = app.config_diagnostic.is_some(); - if let Some(message) = &app.config_diagnostic { - let diagnostic_area = if app.view.layout == ViewLayout::Mobile { - terminal_area - } else { - frame.area() - }; - render_config_diagnostic(frame, diagnostic_area, message, &app.palette); - } - let mut copy_feedback_offset = u16::from(has_config_diagnostic); - let mut toast_rect = None; - if let Some(toast) = &app.toast { - if app.view.layout == ViewLayout::Mobile { - render_mobile_toast_banner( - frame, - frame.area(), - toast, - has_config_diagnostic, - &app.palette, - ); - } else { - render_toast_notification( - frame, - frame.area(), - toast, - has_config_diagnostic, - toast.position.unwrap_or(app.toast_config.herdr.position), - &app.palette, - ); - toast_rect = Some(toast_notification_rect( - frame.area(), - toast, - has_config_diagnostic, - toast.position.unwrap_or(app.toast_config.herdr.position), - )); - } - if app.view.layout == ViewLayout::Mobile { - toast_rect = Some(mobile_toast_banner_rect( - frame.area(), - has_config_diagnostic, - )); - } - } - if let Some(feedback) = &app.copy_feedback { - let area = if app.view.layout == ViewLayout::Mobile { - frame.area() - } else { - terminal_area - }; - if let Some(toast_rect) = toast_rect { - copy_feedback_offset = copy_feedback_offset_for_toast( - area, - feedback, - copy_feedback_offset, - app.toast_config.clipboard.position, - toast_rect, - ); - } - render_copy_feedback( - frame, - area, - feedback, - copy_feedback_offset, - app.toast_config.clipboard.position, - &app.palette, - ); } } @@ -564,1010 +129,16 @@ pub(crate) fn copy_feedback_offset_for_toast( toast_rect: Rect, ) -> u16 { let feedback_rect = copy_feedback_rect(area, feedback, base_offset, position); - if rects_overlap(feedback_rect, toast_rect) { + if rectangles_overlap(feedback_rect, toast_rect) { base_offset.saturating_add(toast_rect.height) } else { base_offset } } -fn rects_overlap(a: Rect, b: Rect) -> bool { - a.x < b.x.saturating_add(b.width) - && b.x < a.x.saturating_add(a.width) - && a.y < b.y.saturating_add(b.height) - && b.y < a.y.saturating_add(a.height) -} - -fn dim_background(frame: &mut Frame, area: Rect) { - let buf = frame.buffer_mut(); - for y in area.y..area.y + area.height { - for x in area.x..area.x + area.width { - let cell = &mut buf[(x, y)]; - cell.set_style(cell.style().add_modifier(Modifier::DIM)); - } - } -} - -/// Floating overlay for navigate mode — appears at bottom of terminal area. -fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> Vec> { - let mut spans = Vec::new(); - spans.push(Span::raw(" ")); - for (i, (k, desc)) in items.iter().enumerate() { - if i > 0 { - spans.push(Span::styled(" ", dim_style)); - } - spans.push(Span::styled(k.to_string(), key_style)); - spans.push(Span::styled(format!(" {desc}"), dim_style)); - } - spans -} - -#[cfg(test)] -mod tests { - use super::scrollbar::scrollbar_thumb; - use super::*; - use crate::{app::state::ViewLayout, layout::PaneInfo, workspace::Workspace}; - use ratatui::style::Color; - use ratatui::{backend::TestBackend, Terminal}; - - fn keybind_help_groups(app: &AppState) -> Vec { - crate::input::keybind_help_groups(&app.keybinds, (app.prefix_code, app.prefix_mods)) - } - - #[test] - fn copy_feedback_offset_only_increases_when_toast_rect_overlaps() { - let area = Rect::new(0, 0, 80, 24); - let feedback = crate::app::state::CopyFeedback { - message: "copied to clipboard".into(), - }; - let toast = crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "pi finished".into(), - context: "workspace · 1".into(), - position: None, - target: None, - }; - - let bottom_right_toast = toast_notification_rect( - area, - &toast, - false, - crate::config::ToastHerdrPosition::BottomRight, - ); - assert_eq!( - copy_feedback_offset_for_toast( - area, - &feedback, - 0, - crate::config::ToastClipboardPosition::TopCenter, - bottom_right_toast, - ), - 0 - ); - - let bottom_center_toast = Rect::new(28, 21, 24, 3); - assert_eq!( - copy_feedback_offset_for_toast( - area, - &feedback, - 0, - crate::config::ToastClipboardPosition::BottomCenter, - bottom_center_toast, - ), - bottom_center_toast.height - ); - } - - #[test] - fn workspace_creation_dialog_renders_new_workspace_title() { - let mut app = crate::app::state::AppState::test_new(); - app.mode = Mode::RenameWorkspace; - app.pending_workspace_create_cwd = Some("/tmp/project".into()); - app.name_input = "project".into(); - - let area = Rect::new(0, 0, 80, 20); - compute_view(&mut app, area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let screen = (0..area.height) - .map(|row| buffer_row_text(terminal.backend().buffer(), area, row)) - .collect::>() - .join("\n"); - - assert!(screen.contains("new workspace"), "{screen}"); - assert!(screen.contains("project"), "{screen}"); - } - - #[tokio::test] - async fn focused_pane_cursor_wins_during_terminal_render() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal); - - ws.insert_test_runtime( - first_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), - ); - ws.insert_test_runtime( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"r\r\nb"), - ); - ws.tabs[0].layout.focus_pane(first_pane); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - let focused = app - .view - .pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("focused pane info"); - - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - - terminal - .backend_mut() - .assert_cursor_position((focused.inner_rect.x + 4, focused.inner_rect.y)); - } - - #[test] - fn mobile_width_uses_header_and_full_width_terminal() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 44, 20)); - - assert_eq!(app.view.layout, ViewLayout::Mobile); - assert_eq!(app.view.sidebar_rect, Rect::default()); - assert_eq!(app.view.tab_bar_rect, Rect::default()); - assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 44, 2)); - assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18)); - assert_eq!(app.view.mobile_menu_hit_area.height, 2); - assert_eq!( - app.view.mobile_menu_hit_area.x + app.view.mobile_menu_hit_area.width, - 44 - ); - } - - #[test] - fn mobile_config_diagnostic_keeps_command_visible() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.config_diagnostic = Some("config.toml:100:10; herdr config check".into()); - - let area = Rect::new(0, 0, 44, 20); - compute_view(&mut app, area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let row = buffer_row_text(terminal.backend().buffer(), area, app.view.terminal_area.y); - - assert!(row.contains("config.toml:100:10"), "{row}"); - assert!(row.contains("herdr config check"), "{row}"); - } - - #[test] - fn desktop_toast_hit_area_uses_full_frame_not_terminal_area() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.toast_config.herdr.position = crate::config::ToastHerdrPosition::TopLeft; - app.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "pi finished".into(), - context: "one".into(), - position: None, - target: None, - }); - - compute_view(&mut app, Rect::new(0, 0, 100, 20)); - - assert_eq!(app.view.layout, ViewLayout::Desktop); - assert!(app.view.terminal_area.x > 0); - assert_eq!(app.view.toast_hit_area.x, 0); - assert_eq!(app.view.toast_hit_area.y, 0); - } - - #[test] - fn desktop_toast_hit_area_still_offsets_for_config_diagnostic() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.config_diagnostic = Some("config warning".into()); - app.toast_config.herdr.position = crate::config::ToastHerdrPosition::TopLeft; - app.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "pi finished".into(), - context: "one".into(), - position: None, - target: None, - }); - - compute_view(&mut app, Rect::new(0, 0, 100, 20)); - - assert_eq!(app.view.toast_hit_area.x, 0); - assert_eq!(app.view.toast_hit_area.y, 1); - } - - #[test] - fn configured_mobile_width_threshold_controls_layout_switch() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - assert_eq!(app.view.layout, ViewLayout::Desktop); - - app.mobile_width_threshold = 90; - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - assert_eq!(app.view.layout, ViewLayout::Mobile); - assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 80, 2)); - assert_eq!(app.view.terminal_area, Rect::new(0, 2, 80, 18)); - } - - #[test] - fn desktop_tab_bar_position_controls_geometry_and_mode_bar_placement() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Prefix; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 54, 1)); - assert_eq!(app.view.terminal_area, Rect::new(26, 1, 54, 19)); - - app.tab_bar_position = crate::config::TabBarPositionConfig::Bottom; - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - assert_eq!(app.view.terminal_area, Rect::new(26, 0, 54, 19)); - assert_eq!(app.view.tab_bar_rect, Rect::new(26, 19, 54, 1)); - assert!(app.view.tab_hit_areas.iter().all(|rect| rect.y == 19)); - assert_eq!(app.view.new_tab_hit_area.y, 19); - - let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let mode_row = buffer_row_text( - terminal.backend().buffer(), - app.view.tab_bar_rect, - app.view.tab_bar_rect.y, - ); - assert!(mode_row.contains("PREFIX"), "{mode_row}"); - } - - #[test] - fn hide_tab_bar_when_single_tab_toggles_geometry_with_tab_count() { - let mut app = crate::app::state::AppState::test_new(); - app.hide_tab_bar_when_single_tab = true; - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - let single_tab_terminal_area = app.view.terminal_area; - assert_eq!(app.view.tab_bar_rect, Rect::default()); - assert_eq!(single_tab_terminal_area, Rect::new(26, 0, 54, 20)); - assert!(app.view.tab_hit_areas.is_empty()); - assert_eq!(app.view.new_tab_hit_area, Rect::default()); - - app.workspaces[0].test_add_tab(Some("logs")); - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 54, 1)); - assert_eq!(app.view.terminal_area, Rect::new(26, 1, 54, 19)); - assert_eq!(app.view.tab_hit_areas.len(), 2); - assert!(app.view.tab_hit_areas.iter().all(|rect| rect.width > 0)); - assert!(app.view.new_tab_hit_area.width > 0); - - assert!(app.workspaces[0].close_tab(1)); - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - assert_eq!(app.view.terminal_area, single_tab_terminal_area); - assert_eq!(app.view.tab_bar_rect, Rect::default()); - assert!(app.view.tab_hit_areas.is_empty()); - assert_eq!(app.view.new_tab_hit_area, Rect::default()); - } - - #[test] - fn bottom_tab_bar_still_hides_when_single_tab() { - let mut app = crate::app::state::AppState::test_new(); - app.hide_tab_bar_when_single_tab = true; - app.tab_bar_position = crate::config::TabBarPositionConfig::Bottom; - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Prefix; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - assert_eq!(app.view.tab_bar_rect, Rect::default()); - assert_eq!(app.view.terminal_area, Rect::new(26, 0, 54, 20)); - - let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let mode_row = buffer_row_text( - terminal.backend().buffer(), - app.view.terminal_area, - app.view.terminal_area.y + app.view.terminal_area.height - 1, - ); - assert!(mode_row.contains("PREFIX"), "{mode_row}"); - } - - #[tokio::test] - async fn hide_tab_bar_when_single_tab_resizes_background_tabs_per_workspace() { - let mut app = crate::app::state::AppState::test_new(); - app.hide_tab_bar_when_single_tab = true; - - let mut one_tab_workspace = Workspace::test_new("one"); - let one_tab_pane = one_tab_workspace.tabs[0].root_pane; - let one_tab_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""); - one_tab_workspace.tabs[0] - .runtimes - .insert(one_tab_pane, one_tab_runtime); - - let mut two_tab_workspace = Workspace::test_new("two"); - let background_tab = two_tab_workspace.test_add_tab(Some("logs")); - let two_tab_pane = two_tab_workspace.tabs[background_tab].root_pane; - let two_tab_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""); - two_tab_workspace.tabs[background_tab] - .runtimes - .insert(two_tab_pane, two_tab_runtime); - - app.workspaces = vec![one_tab_workspace, two_tab_workspace]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - let one_tab_size = app.workspaces[0].tabs[0].runtimes[&one_tab_pane].current_size(); - let two_tab_size = - app.workspaces[1].tabs[background_tab].runtimes[&two_tab_pane].current_size(); - assert_eq!(one_tab_size, (20, 53)); - assert_eq!(two_tab_size, (19, 53)); - } - - #[tokio::test] - async fn mobile_background_tabs_use_mobile_terminal_area() { - let mut app = crate::app::state::AppState::test_new(); - - let mut workspace = Workspace::test_new("mobile"); - let background_tab = workspace.test_add_tab(Some("logs")); - let background_pane = workspace.tabs[background_tab].root_pane; - let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""); - workspace.tabs[background_tab] - .runtimes - .insert(background_pane, runtime); - - app.workspaces = vec![workspace]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 44, 20)); - - assert_eq!(app.view.layout, ViewLayout::Mobile); - assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18)); - assert_eq!( - app.workspaces[0].tabs[background_tab].runtimes[&background_pane].current_size(), - (18, 43) - ); - } - - #[test] - fn product_announcement_renders_above_config_diagnostic() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::ProductAnnouncement; - app.product_announcement = Some(crate::app::state::ProductAnnouncementState { - version: "0.6.0".into(), - id: "keybinding-v2".into(), - title: "Keybinding syntax changed".into(), - body: "### Update\n- Body".into(), - scroll: 0, - preview: false, - }); - app.config_diagnostic = Some( - "unsafe direct keybinding: keys.new_workspace = \"n\"\nunsafe direct keybinding: keys.new_tab = \"c\"" - .into(), - ); - - let area = Rect::new(0, 0, 44, 20); - compute_view(&mut app, area); - - let backend = TestBackend::new(area.width, area.height); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let buffer = terminal.backend().buffer(); - - let popup = centered_popup_rect( - area, - PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, - PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1, - ) - .expect("announcement popup"); - let title_row = popup.y + 1; - let row = buffer_row_text(buffer, Rect::new(0, title_row, area.width, 1), title_row); - - assert!(row.contains("Keybinding syntax changed")); - assert!(!row.contains("config warning")); - } - - #[test] - fn compute_view_clamps_sidebar_width_to_configured_max() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.sidebar_max_width = 30; - app.sidebar_width = 999; - - compute_view(&mut app, Rect::new(0, 0, 100, 20)); - - assert_eq!(app.view.sidebar_rect.width, 30); - } - - #[test] - fn compute_view_clamps_sidebar_width_to_configured_min() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.sidebar_min_width = 22; - app.sidebar_width = 5; - - compute_view(&mut app, Rect::new(0, 0, 100, 20)); - - assert_eq!(app.view.sidebar_rect.width, 22); - } - - #[test] - fn hidden_collapsed_sidebar_uses_full_width_terminal_area() { - let mut app = crate::app::state::AppState::test_new(); - app.sidebar_collapsed = true; - app.sidebar_collapsed_mode = crate::config::SidebarCollapsedModeConfig::Hidden; - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - assert_eq!(app.view.sidebar_rect, Rect::new(0, 0, 0, 20)); - assert_eq!(app.view.tab_bar_rect, Rect::new(0, 0, 80, 1)); - assert_eq!(app.view.terminal_area, Rect::new(0, 1, 80, 19)); - assert!(app.view.workspace_card_areas.is_empty()); - - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - } - - #[test] - fn collapsed_sidebar_keeps_active_workspace_highlight_in_terminal_mode() { - let mut app = crate::app::state::AppState::test_new(); - app.sidebar_collapsed = true; - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(1); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let buffer = terminal.backend().buffer(); - - let (ws_area, _, _) = collapsed_sidebar_sections(app.view.sidebar_rect); - let active_row = ws_area.y + 1; - let active_style = buffer[(ws_area.x, active_row)].style(); - - assert_eq!(active_style.bg, Some(app.palette.active_row_bg)); - } - - #[test] - fn expanded_sidebar_workspace_rows_show_state_before_name_without_numbers() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("one"); - let repo = temp_git_repo("main"); - ws.identity_cwd = repo.clone(); - let root_pane = ws.tabs[0].root_pane; - ws.refresh_git_ahead_behind(); - - app.workspaces = vec![ws]; - app.ensure_test_terminals(); - let root_terminal_id = app.workspaces[0].tabs[0].panes[&root_pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&root_terminal_id).unwrap().cwd = repo.clone(); - app.selected = 0; - app.mode = Mode::Navigate; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let buffer = terminal.backend().buffer(); - - let card = app.view.workspace_card_areas[0].rect; - let line1 = buffer_row_text(buffer, card, card.y); - let line2 = buffer_row_text(buffer, card, card.y + 1); - - assert!(line1.starts_with(" · one")); - assert!(!line1.contains("1 one")); - assert_eq!(line2, " main"); - - std::fs::remove_dir_all(repo).ok(); - } - - #[test] - fn tab_bar_dims_auto_named_tabs_and_emphasizes_custom_tabs() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - let custom_tab = ws.test_add_tab(Some("logs")); - ws.switch_tab(custom_tab); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let buffer = terminal.backend().buffer(); - - let auto_rect = app.view.tab_hit_areas[0]; - let custom_rect = app.view.tab_hit_areas[1]; - let auto_style = buffer[(auto_rect.x + 1, auto_rect.y)].style(); - let custom_style = buffer[(custom_rect.x + 1, custom_rect.y)].style(); - - assert_eq!(auto_style.fg, Some(app.palette.overlay0)); - assert!(auto_style.add_modifier.contains(Modifier::DIM)); - assert_eq!(custom_style.fg, Some(app.palette.panel_bg)); - assert!(custom_style.add_modifier.contains(Modifier::BOLD)); - } - - #[test] - fn tab_bar_uses_surface_dim_when_panel_background_resets() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - let custom_tab = ws.test_add_tab(Some("logs")); - ws.switch_tab(custom_tab); - - app.palette.panel_bg = Color::Reset; - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let buffer = terminal.backend().buffer(); - - let custom_rect = app.view.tab_hit_areas[1]; - let custom_style = buffer[(custom_rect.x + 1, custom_rect.y)].style(); - - assert_eq!(custom_style.bg, Some(app.palette.accent)); - assert_eq!(custom_style.fg, Some(app.palette.surface_dim)); - assert!(custom_style.add_modifier.contains(Modifier::BOLD)); - } - - #[test] - fn new_tab_button_tracks_rightmost_tab_when_tabs_fit() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("logs")); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - let last_visible = app - .view - .tab_hit_areas - .iter() - .rev() - .find(|rect| rect.width > 0) - .copied() - .expect("last visible tab"); - - assert_eq!( - app.view.new_tab_hit_area.x, - last_visible.x + last_visible.width - ); - } - - #[test] - fn tab_bar_shows_scroll_controls_when_tabs_overflow() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - for name in ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"] { - ws.test_add_tab(Some(name)); - } - - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.tab_scroll_follow_active = false; - app.tab_scroll = 2; - - compute_view(&mut app, Rect::new(0, 0, 65, 20)); - - assert!(app.view.tab_scroll_left_hit_area.width > 0); - assert!(app.view.tab_scroll_right_hit_area.width > 0); - assert_eq!(app.view.tab_hit_areas[0].width, 0); - assert_eq!(app.view.tab_hit_areas[1].width, 0); - assert!(app.view.tab_hit_areas[2].width > 0); - assert!(app.view.new_tab_hit_area.width > 0); - - let last_visible = app - .view - .tab_hit_areas - .iter() - .rev() - .find(|rect| rect.width > 0) - .copied() - .expect("last visible tab"); - - assert_eq!( - app.view.tab_scroll_right_hit_area.x, - last_visible.x + last_visible.width - ); - assert_eq!( - app.view.new_tab_hit_area.x, - app.view.tab_scroll_right_hit_area.x + app.view.tab_scroll_right_hit_area.width - ); - } - - #[test] - fn tab_bar_clamps_manual_scroll_at_last_visible_tab() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - for name in [ - "one", "two", "three", "four", "five", "six", "seven", "eight", - ] { - ws.test_add_tab(Some(name)); - } - - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.tab_scroll_follow_active = false; - app.tab_scroll = usize::MAX; - - compute_view(&mut app, Rect::new(0, 0, 65, 20)); - - let last_idx = app.workspaces[0].tabs.len() - 1; - assert!(app.view.tab_hit_areas[last_idx].width > 0); - let clamped_scroll = app.tab_scroll; - - app.scroll_tabs_right(); - - assert_eq!(app.tab_scroll, clamped_scroll); - assert!(app.view.tab_hit_areas[last_idx].width > 0); - } - - #[test] - fn pane_scrollbar_rect_uses_reserved_rightmost_column() { - let info = PaneInfo { - id: crate::layout::PaneId::from_raw(1), - rect: Rect::new(0, 0, 12, 8), - inner_rect: Rect::new(1, 1, 9, 6), - scrollbar_rect: Some(Rect::new(10, 1, 1, 6)), - borders: ratatui::widgets::Borders::ALL, - is_focused: true, - }; - - assert_eq!(pane_scrollbar_rect(&info), Some(Rect::new(10, 1, 1, 6))); - } - - #[tokio::test] - async fn compute_view_reserves_terminal_column_when_pane_scrollbar_is_visible() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - 12, - 4, - 4096, - b"000000000000\r\n111111111111\r\n222222222222\r\n333333333333\r\n444444444444\r\n", - ), - ); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - - compute_view(&mut app, Rect::new(0, 0, 40, 12)); - - let info = app.view.pane_infos.first().expect("pane info"); - assert_eq!(info.inner_rect.width + 1, app.view.terminal_area.width); - assert_eq!( - info.scrollbar_rect, - Some(Rect::new( - info.inner_rect.x + info.inner_rect.width, - info.inner_rect.y, - 1, - info.inner_rect.height, - )) - ); - } - - #[test] - fn scrollbar_stays_hidden_without_scrollback() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 0, - max_offset_from_bottom: 0, - viewport_rows: 5, - }; - - assert!(!should_show_scrollbar(metrics)); - } - - #[test] - fn scrollbar_shows_with_scrollback() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 0, - max_offset_from_bottom: 20, - viewport_rows: 5, - }; - - assert!(should_show_scrollbar(metrics)); - } - - #[test] - fn scrollbar_thumb_reaches_bottom_when_scrolled_to_bottom() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 0, - max_offset_from_bottom: 20, - viewport_rows: 5, - }; - let track = Rect::new(9, 4, 1, 5); - - let thumb = scrollbar_thumb(metrics, track).expect("thumb"); - assert_eq!(thumb.top + thumb.len, track.y + track.height); - } - - #[test] - fn scrollbar_offset_mapping_hits_top_middle_and_bottom() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 0, - max_offset_from_bottom: 20, - viewport_rows: 5, - }; - let track = Rect::new(9, 4, 1, 5); - - assert_eq!(scrollbar_offset_from_row(metrics, track, 4), 20); - assert_eq!(scrollbar_offset_from_row(metrics, track, 6), 10); - assert_eq!(scrollbar_offset_from_row(metrics, track, 8), 0); - } - - #[test] - fn dragging_from_current_thumb_row_preserves_offset() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 7, - max_offset_from_bottom: 20, - viewport_rows: 5, - }; - let track = Rect::new(9, 4, 1, 8); - let thumb = scrollbar_thumb(metrics, track).expect("thumb"); - let row = thumb.top + thumb.len / 2; - let grab = scrollbar_thumb_grab_offset(metrics, track, row).expect("grab"); - - assert_eq!(scrollbar_offset_from_drag_row(metrics, track, row, grab), 7); - } - - fn buffer_row_text(buffer: &ratatui::buffer::Buffer, area: Rect, row: u16) -> String { - (area.x..area.x + area.width) - .map(|x| buffer[(x, row)].symbol()) - .collect::() - .trim_end() - .to_string() - } - - fn temp_git_repo(branch: &str) -> std::path::PathBuf { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("unix time") - .as_nanos(); - let root = std::env::temp_dir().join(format!("herdr-ui-test-{unique}")); - std::fs::create_dir_all(root.join(".git")).expect("create .git dir"); - std::fs::write( - root.join(".git/HEAD"), - format!("ref: refs/heads/{branch}\n"), - ) - .expect("write HEAD"); - root - } - - #[test] - fn prefix_mode_renders_prefix_indicator() { - let mut app = crate::app::state::AppState::test_new(); - app.mode = Mode::Prefix; - app.view.terminal_area = ratatui::layout::Rect::new(0, 0, 60, 4); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 4)) - .expect("test terminal"); - - terminal - .draw(|frame| render_prefix_overlay(&app, frame, app.view.terminal_area)) - .expect("draw prefix overlay"); - - let rendered = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect::(); - assert!(rendered.contains("PREFIX")); - } - - #[test] - fn keybind_help_shows_unset_for_optional_actions() { - let app = crate::app::state::AppState::test_new(); - let groups = keybind_help_groups(&app); - - let workspace_tab = groups - .iter() - .find(|(name, _)| *name == "workspaces / tabs") - .expect("workspace tab group") - .1 - .clone(); - let panes = groups - .iter() - .find(|(name, _)| *name == "panes") - .expect("panes group") - .1 - .clone(); - - assert!(workspace_tab - .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "previous workspace")); - assert!(workspace_tab - .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "next workspace")); - assert!(workspace_tab - .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "previous agent")); - assert!(workspace_tab - .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "next agent")); - assert!(workspace_tab - .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "focus agent 1-9")); - assert!(workspace_tab - .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "switch workspace 1-9")); - assert!(panes - .iter() - .any(|(key, label)| key == "prefix+h" && label.as_ref() == "focus pane left")); - assert!(panes - .iter() - .any(|(key, label)| key == "prefix+j" && label.as_ref() == "focus pane down")); - assert!(panes - .iter() - .any(|(key, label)| key == "prefix+k" && label.as_ref() == "focus pane up")); - assert!(panes - .iter() - .any(|(key, label)| key == "prefix+l" && label.as_ref() == "focus pane right")); - } - - #[test] - fn keybind_help_shows_custom_command_descriptions() { - let mut app = crate::app::state::AppState::test_new(); - app.keybinds.custom_commands = vec![ - crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("alt+g"), - label: "prefix+alt+g".to_string(), - command: "lazygit".to_string(), - action: crate::config::CustomCommandAction::Pane, - description: Some("open lazygit".to_string()), - width: None, - height: None, - }, - crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("alt+h"), - label: "prefix+alt+h".to_string(), - command: "echo hello".to_string(), - action: crate::config::CustomCommandAction::Shell, - description: None, - width: None, - height: None, - }, - ]; - - let groups = keybind_help_groups(&app); - let custom = groups - .iter() - .find(|(name, _)| *name == "custom") - .expect("custom group") - .1 - .clone(); - assert!(custom - .iter() - .any(|(key, label)| key == "prefix+alt+g" && label.as_ref() == "open lazygit")); - assert!(custom - .iter() - .any(|(key, label)| key == "prefix+alt+h" && label.as_ref() == "custom command")); - - let rendered_help = keybind_help_lines(&app) - .into_iter() - .flat_map(|(_, line)| line.spans) - .map(|span| span.content.into_owned()) - .collect::>() - .join(""); - assert!(rendered_help.contains("open lazygit")); - assert!(rendered_help.contains("custom command")); - } - - #[test] - fn keybind_help_compacts_multiple_indexed_ranges() { - let config: crate::config::Config = toml::from_str( - r#" -[keys] -switch_tab = ["prefix+1..9", "alt+1..9"] -switch_workspace = "ctrl+1..9" -"#, - ) - .expect("config parses"); - - let mut app = crate::app::state::AppState::test_new(); - app.keybinds = config.keybinds(); - - let workspace_tab = keybind_help_groups(&app) - .into_iter() - .find(|(name, _)| *name == "workspaces / tabs") - .expect("workspace tab group") - .1; - - let switch_tab_key = workspace_tab - .iter() - .find(|(_, label)| label.as_ref() == "switch tab 1-9") - .map(|(key, _)| key.as_str()) - .expect("switch tab help entry"); - let switch_workspace_key = workspace_tab - .iter() - .find(|(_, label)| label.as_ref() == "switch workspace 1-9") - .map(|(key, _)| key.as_str()) - .expect("switch workspace help entry"); - - assert_eq!(switch_tab_key, "prefix+1..9 / alt+1..9"); - assert_eq!(switch_workspace_key, "ctrl+1..9"); - } +fn rectangles_overlap(left: Rect, right: Rect) -> bool { + left.x < right.right() + && right.x < left.right() + && left.y < right.bottom() + && right.y < left.bottom() } diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs deleted file mode 100644 index ff6ced3d..00000000 --- a/src/ui/dialogs.rs +++ /dev/null @@ -1,1158 +0,0 @@ -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, Paragraph, Wrap}, - Frame, -}; - -use super::text::{display_width_u16, truncate_end}; -use super::widgets::{ - action_button_row_rects, centered_popup_rect, panel_contrast_fg, render_action_button, - render_modal_header, render_modal_shell, render_panel_shell, ActionButtonSpec, -}; -use crate::app::{state::WorktreeOpenState, AppState, Mode}; -use crate::terminal::TerminalRuntimeRegistry; - -const NEW_LINKED_WORKTREE_POPUP_WIDTH: u16 = 68; -const NEW_LINKED_WORKTREE_POPUP_HEIGHT: u16 = 12; - -pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "save", - }, - ActionButtonSpec { - hint: Some("^c"), - label: "clear", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - 3, - ); - (rects[0], rects[1], rects[2]) -} - -/// Draws the shared `name_input` field and puts the host cursor on its caret. -/// -/// IMEs draw their composition preview at the host terminal cursor. Without an -/// explicit cursor the frame carries none, the client keeps the position last -/// reported by the focused pane, and composition lands behind the dialog. -fn render_name_input_field(app: &AppState, frame: &mut Frame, input_rect: Rect) { - frame.render_widget(Clear, input_rect); - - // The text stops one column short of the field so the clamped caret always - // lands on a blank cell: a host terminal inverts the cell under its cursor, - // and an IME composes there. - let text_rect = Rect { - width: input_rect.width.saturating_sub(1), - ..input_rect - }; - frame.render_widget( - Paragraph::new(format!(" {}", app.name_input)).style( - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0), - ), - text_rect, - ); - - if input_rect.width == 0 { - return; - } - let caret_x = input_rect - .x - .saturating_add(1) - .saturating_add(display_width_u16(&app.name_input)) - .min(input_rect.right().saturating_sub(1)); - frame.set_cursor_position((caret_x, input_rect.y)); -} - -pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - super::dim_background(frame, area); - - let title = match app.mode { - Mode::RenameWorkspace if app.pending_workspace_create_cwd.is_some() => "new workspace", - Mode::RenameWorkspace => "rename workspace", - Mode::RenameTab if app.creating_new_tab => "new tab", - Mode::RenameTab => "rename tab", - Mode::RenamePane => "rename pane", - _ => return, - }; - - let Some(inner) = render_modal_shell(frame, area, 56, 7, &app.palette) else { - return; - }; - if inner.height < 4 { - return; - } - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<5>(inner); - - render_modal_header(frame, rows[0], title, &app.palette); - - let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1); - render_name_input_field(app, frame, input_rect); - - let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner); - - render_action_button( - frame, - save_rect, - Some("↵"), - "save", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - clear_rect, - Some("^c"), - "clear", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); -} - -pub(crate) fn new_linked_worktree_inner_rect(area: Rect) -> Option { - centered_popup_rect( - area, - NEW_LINKED_WORKTREE_POPUP_WIDTH, - NEW_LINKED_WORKTREE_POPUP_HEIGHT, - ) - .map(|popup| { - Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ) - }) -} - -pub(crate) fn new_linked_worktree_button_rects(inner: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "create and open", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (rects[0], rects[1]) -} - -pub(crate) fn remove_worktree_popup_rect(area: Rect) -> Option { - centered_popup_rect(area, 72, 10) -} - -pub(crate) fn remove_worktree_button_rects(inner: Rect, force_confirmation: bool) -> (Rect, Rect) { - let primary_label = if force_confirmation { - "delete anyway" - } else { - "remove" - }; - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: primary_label, - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (rects[0], rects[1]) -} - -pub(crate) fn open_existing_worktree_inner_rect(area: Rect, entry_count: usize) -> Option { - let height = (entry_count as u16) - .saturating_mul(2) - .saturating_add(7) - .clamp(12, 26); - centered_popup_rect(area, 96, height).map(|popup| { - Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ) - }) -} - -pub(crate) fn open_existing_worktree_max_visible_rows(inner: Rect) -> usize { - usize::from(inner.height.saturating_sub(5) / 2) -} - -pub(crate) fn open_existing_worktree_visible_start( - open: &WorktreeOpenState, - max_rows: usize, -) -> usize { - let filtered = open.filtered_indices(); - let selected = open.selected_entry_index().unwrap_or(open.selected); - let selected_pos = filtered - .iter() - .position(|idx| *idx == selected) - .unwrap_or(0); - selected_pos.saturating_sub(max_rows.saturating_sub(1)) -} - -pub(crate) fn open_existing_worktree_button_rects(inner: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "open", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (rects[0], rects[1]) -} - -pub(super) fn render_new_linked_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(create) = app.worktree_create.as_ref() else { - return; - }; - - super::dim_background(frame, area); - let Some(inner) = render_modal_shell( - frame, - area, - NEW_LINKED_WORKTREE_POPUP_WIDTH, - NEW_LINKED_WORKTREE_POPUP_HEIGHT, - &app.palette, - ) else { - return; - }; - if inner.height < 9 { - return; - } - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(3), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<8>(inner); - - render_modal_header(frame, rows[0], "new worktree", &app.palette); - - frame.render_widget( - Paragraph::new(" branch").style(Style::default().fg(app.palette.overlay0)), - rows[1], - ); - let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1); - render_name_input_field(app, frame, input_rect); - - let checkout = create.checkout_path.display().to_string(); - frame.render_widget( - Paragraph::new(" checkout").style(Style::default().fg(app.palette.overlay0)), - rows[3], - ); - frame.render_widget( - Paragraph::new(format!(" {checkout}")).style(Style::default().fg(app.palette.subtext0)), - rows[4], - ); - - if create.creating { - frame.render_widget( - Paragraph::new(" creating…").style(Style::default().fg(app.palette.overlay0)), - rows[5], - ); - } else if let Some(error) = &create.error { - frame.render_widget( - Paragraph::new(format!(" {error}")) - .style(Style::default().fg(app.palette.red)) - .wrap(Wrap { trim: false }), - rows[5], - ); - } - - let (create_rect, cancel_rect) = new_linked_worktree_button_rects(inner); - render_action_button( - frame, - create_rect, - Some("↵"), - "create and open", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); -} - -pub(super) fn render_remove_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(remove) = app.worktree_remove.as_ref() else { - return; - }; - - super::dim_background(frame, area); - let Some(popup) = remove_worktree_popup_rect(area) else { - return; - }; - let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg) - else { - return; - }; - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<8>(inner); - - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " delete worktree checkout?", - Style::default() - .fg(app.palette.red) - .add_modifier(Modifier::BOLD), - )])), - rows[0], - ); - frame.render_widget( - Paragraph::new(" This removes the checkout folder:") - .style(Style::default().fg(app.palette.overlay0)), - rows[1], - ); - frame.render_widget( - Paragraph::new(format!(" {}", remove.path.display())) - .style(Style::default().fg(app.palette.text)), - rows[2], - ); - frame.render_widget( - Paragraph::new(" The branch is not deleted. The Herdr workspace will close.") - .style(Style::default().fg(app.palette.overlay0)), - rows[3], - ); - if remove.force_confirmation { - frame.render_widget( - Paragraph::new(" Dirty or untracked files will be permanently deleted.") - .style(Style::default().fg(app.palette.red)), - rows[4], - ); - } - if remove.removing { - frame.render_widget( - Paragraph::new(" removing…").style(Style::default().fg(app.palette.overlay0)), - rows[5], - ); - } else if let Some(error) = &remove.error { - frame.render_widget( - Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)), - rows[5], - ); - } - - let (remove_rect, cancel_rect) = remove_worktree_button_rects(inner, remove.force_confirmation); - let remove_label = if remove.force_confirmation { - "delete anyway" - } else { - "remove" - }; - render_action_button( - frame, - remove_rect, - Some("↵"), - remove_label, - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.red) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); -} - -pub(super) fn render_open_existing_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(open) = app.worktree_open.as_ref() else { - return; - }; - - super::dim_background(frame, area); - let height = (open.entries.len() as u16) - .saturating_mul(2) - .saturating_add(7) - .clamp(12, 26); - let Some(inner) = render_modal_shell(frame, area, 96, height, &app.palette) else { - return; - }; - if inner.height < 8 { - return; - } - - render_modal_header( - frame, - Rect::new(inner.x, inner.y, inner.width, 1), - "open worktree", - &app.palette, - ); - render_open_worktree_search( - app, - frame, - Rect::new(inner.x, inner.y + 1, inner.width, 1), - open, - ); - frame.render_widget( - Paragraph::new("─".repeat(inner.width as usize)) - .style(Style::default().fg(app.palette.surface1)), - Rect::new(inner.x, inner.y.saturating_add(2), inner.width, 1), - ); - - let filtered = open.filtered_indices(); - let max_rows = open_existing_worktree_max_visible_rows(inner); - let start = open_existing_worktree_visible_start(open, max_rows); - for (visible_idx, entry_idx) in filtered.iter().skip(start).take(max_rows).enumerate() { - let Some(entry) = open.entries.get(*entry_idx) else { - continue; - }; - let selected = Some(*entry_idx) == open.selected_entry_index(); - let y = inner.y.saturating_add(3 + (visible_idx as u16 * 2)); - let marker = if selected { "›" } else { " " }; - let row_style = if selected { - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(app.palette.subtext0) - }; - let path_style = if selected { - Style::default() - .fg(app.palette.subtext0) - .bg(app.palette.surface0) - } else { - Style::default().fg(app.palette.overlay0) - }; - let status = entry.status_label(); - let title_width = inner - .width - .saturating_sub(display_width_u16(status)) - .saturating_sub(4) as usize; - let mut title = format!( - "{marker} {}", - truncate_end(&entry.display_name(), title_width) - ); - if !status.is_empty() { - let pad = inner - .width - .saturating_sub(display_width_u16(&title)) - .saturating_sub(display_width_u16(status)) - .max(1); - title.push_str(&" ".repeat(pad as usize)); - title.push_str(status); - } - frame.render_widget( - Paragraph::new(truncate_end(&title, inner.width as usize)).style(row_style), - Rect::new(inner.x, y, inner.width, 1), - ); - frame.render_widget( - Paragraph::new(truncate_end( - &format!(" {}", entry.path.display()), - inner.width as usize, - )) - .style(path_style), - Rect::new(inner.x, y.saturating_add(1), inner.width, 1), - ); - } - - if filtered.is_empty() { - frame.render_widget( - Paragraph::new(" no matching worktrees") - .style(Style::default().fg(app.palette.overlay0)), - Rect::new(inner.x, inner.y.saturating_add(3), inner.width, 1), - ); - } - - if let Some(error) = &open.error { - frame.render_widget( - Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)), - Rect::new( - inner.x, - inner.y + inner.height.saturating_sub(2), - inner.width, - 1, - ), - ); - } - - let (open_rect, cancel_rect) = open_existing_worktree_button_rects(inner); - render_action_button( - frame, - open_rect, - Some("↵"), - "open", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); -} - -fn render_open_worktree_search( - app: &AppState, - frame: &mut Frame, - area: Rect, - open: &WorktreeOpenState, -) { - let focus_style = if open.search_focused { - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(app.palette.overlay0) - }; - let filtered_count = open.filtered_indices().len(); - let count = if open.query.trim().is_empty() { - format!("{} checkouts", open.entries.len()) - } else { - format!("{filtered_count}/{} checkouts", open.entries.len()) - }; - let mut spans = vec![Span::styled(" / ", focus_style)]; - if open.query.trim().is_empty() { - spans.push(Span::styled( - "filter worktrees", - Style::default().fg(app.palette.overlay0), - )); - } else { - spans.push(Span::styled( - open.query.clone(), - Style::default().fg(app.palette.text), - )); - } - spans.push(Span::styled( - format!( - "{count:>width$}", - width = area.width.saturating_sub(18) as usize - ), - Style::default().fg(app.palette.overlay0), - )); - frame.render_widget(Paragraph::new(Line::from(spans)), area); -} - -fn confirm_close_overlay_text( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> (String, String) { - let ws_name = app - .workspaces - .get(app.selected) - .map(|ws| ws.display_name_from(&app.terminals, terminal_runtimes)) - .unwrap_or_else(|| "?".to_string()); - let selected_space = app - .workspaces - .get(app.selected) - .and_then(|ws| ws.worktree_space()); - let group_member_indices = selected_space - .filter(|space| !space.is_linked_worktree) - .map(|space| { - app.workspaces - .iter() - .enumerate() - .filter_map(|(idx, ws)| { - ws.worktree_space() - .is_some_and(|member| member.key == space.key) - .then_some(idx) - }) - .collect::>() - }) - .unwrap_or_default(); - let closes_group = group_member_indices.len() > 1; - let pane_count = if closes_group { - group_member_indices - .iter() - .filter_map(|idx| app.workspaces.get(*idx)) - .map(|ws| ws.layout.pane_count()) - .sum() - } else { - app.workspaces - .get(app.selected) - .map(|ws| ws.layout.pane_count()) - .unwrap_or(0) - }; - - let pane_text = if pane_count == 1 { - "1 pane".to_string() - } else { - format!("{pane_count} panes") - }; - let workspace_text = if closes_group { - let count = group_member_indices.len(); - if count == 1 { - "1 workspace, ".to_string() - } else { - format!("{count} workspaces, ") - } - } else { - String::new() - }; - - let title = if closes_group { - "Close worktree group?" - } else { - "Close workspace?" - }; - let detail = format!("{ws_name} — {workspace_text}{pane_text}"); - (title.to_string(), detail) -} - -pub(super) fn render_confirm_close_overlay( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - let (title, detail) = confirm_close_overlay_text(app, terminal_runtimes); - - super::dim_background(frame, area); - - let Some(popup) = confirm_close_popup_rect(area) else { - return; - }; - - let warn = Style::default() - .fg(app.palette.red) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - - let title_line = Line::from(vec![Span::styled(format!(" {title}"), warn)]); - - let detail_line = Line::from(vec![ - Span::styled( - format!(" {}", detail.split(" — ").next().unwrap_or(&detail)), - Style::default() - .fg(app.palette.text) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - detail - .split_once(" — ") - .map(|(_, rest)| format!(" — {rest}")) - .unwrap_or_default(), - dim, - ), - ]); - - let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg) - else { - return; - }; - - if inner.height >= 3 { - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas::<4>(inner); - - frame.render_widget(Paragraph::new(title_line), rows[0]); - frame.render_widget(Paragraph::new(detail_line), rows[1]); - - let (confirm_rect, cancel_rect) = confirm_close_button_rects(inner); - render_action_button( - frame, - confirm_rect, - Some("↵"), - "confirm", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.red) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); - } -} - -pub(crate) fn confirm_close_popup_rect(area: Rect) -> Option { - centered_popup_rect(area, 64, 6) -} - -pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "confirm", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - 3, - ); - (rects[0], rects[1]) -} - -#[cfg(test)] -mod tests { - use crate::{ - app::{state::WorktreeCreateState, AppState, Mode}, - workspace::Workspace, - }; - use ratatui::{ - backend::TestBackend, - buffer::Buffer, - layout::{Position, Rect}, - Terminal, - }; - - use super::{ - confirm_close_overlay_text, render_new_linked_worktree_overlay, render_rename_overlay, - }; - - #[test] - fn confirm_close_text_uses_live_workspace_cwd_label() { - let mut app = AppState::test_new(); - let mut workspace = Workspace::test_new("initial"); - workspace.custom_name = None; - workspace.identity_cwd = "/projects/original".into(); - let root_pane = workspace.tabs[0].root_pane; - let terminal_id = workspace.tabs[0].panes[&root_pane] - .attached_terminal_id - .clone(); - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.terminals.get_mut(&terminal_id).unwrap().cwd = "/projects/current".into(); - app.selected = 0; - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let (title, detail) = confirm_close_overlay_text(&app, &terminal_runtimes); - - assert_eq!(title, "Close workspace?"); - assert_eq!(detail, "current — 1 pane"); - } - - #[cfg(unix)] - #[tokio::test] - async fn confirm_close_text_prefers_live_runtime_cwd_over_stale_terminal_cwd() { - let root = std::env::temp_dir().join(format!( - "herdr-confirm-close-runtime-cwd-{}", - std::process::id() - )); - let stale_cwd = root.join("original"); - let live_cwd = root.join("current"); - std::fs::create_dir_all(&live_cwd).unwrap(); - - let mut app = AppState::test_new(); - let mut workspace = Workspace::test_new("initial"); - workspace.custom_name = None; - workspace.identity_cwd = stale_cwd.clone(); - let root_pane = workspace.tabs[0].root_pane; - let terminal_id = workspace.tabs[0].panes[&root_pane] - .attached_terminal_id - .clone(); - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.selected = 0; - - let (events, _) = tokio::sync::mpsc::channel(4); - let runtime = crate::terminal::TerminalRuntime::spawn( - root_pane, - 24, - 80, - live_cwd, - 0, - crate::terminal_theme::TerminalTheme::default(), - None, - crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), - &crate::pane::PaneLaunchEnv::default(), - events, - std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(crate::render_signal::RenderSignal::new()), - ) - .unwrap(); - let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - terminal_runtimes.insert(terminal_id, runtime); - - let (_, detail) = confirm_close_overlay_text(&app, &terminal_runtimes); - - assert_eq!(detail, "current — 1 pane"); - - drop(terminal_runtimes); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn confirm_close_text_uses_selected_custom_name_instead_of_active_workspace_cwd() { - let mut app = AppState::test_new(); - let active = Workspace::test_new("active"); - let selected = Workspace::test_new("selected"); - let selected_root = selected.tabs[0].root_pane; - let selected_terminal_id = selected.tabs[0].panes[&selected_root] - .attached_terminal_id - .clone(); - app.workspaces = vec![active, selected]; - app.ensure_test_terminals(); - app.terminals.get_mut(&selected_terminal_id).unwrap().cwd = "/projects/current".into(); - app.active = Some(0); - app.selected = 1; - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let (_, detail) = confirm_close_overlay_text(&app, &terminal_runtimes); - - assert_eq!(detail, "selected — 1 pane"); - } - - #[test] - fn confirm_close_text_reports_parent_group_scope() { - let mut app = AppState::test_new(); - let mut parent = Workspace::test_new("main"); - parent.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }); - let mut child = Workspace::test_new("issue"); - child.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - app.workspaces = vec![parent, child]; - app.selected = 0; - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let (title, detail) = confirm_close_overlay_text(&app, &terminal_runtimes); - - assert_eq!(title, "Close worktree group?"); - assert_eq!(detail, "main — 2 workspaces, 2 panes"); - } - - #[test] - fn new_worktree_error_renders_fatal_stderr_line() { - let mut app = AppState::test_new(); - app.name_input = "foo".into(); - app.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "foo".into(), - checkout_path: "/repo/.worktrees/herdr/foo".into(), - error: Some( - "Preparing worktree (new branch 'foo')\nfatal: a branch named 'foo' already exists" - .into(), - ), - creating: false, - }); - - let mut terminal = - Terminal::new(TestBackend::new(100, 30)).expect("test terminal should initialize"); - terminal - .draw(|frame| render_new_linked_worktree_overlay(&app, frame, Rect::new(0, 0, 100, 30))) - .expect("new worktree overlay should render"); - let rendered = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect::(); - - assert!(rendered.contains("fatal: a branch named 'foo' already exists")); - } - - #[test] - fn new_worktree_hit_test_geometry_matches_modal_size() { - let area = Rect::new(0, 0, 100, 30); - let inner = super::new_linked_worktree_inner_rect(area).unwrap(); - let (create, cancel) = super::new_linked_worktree_button_rects(inner); - - assert_eq!(inner.width, super::NEW_LINKED_WORKTREE_POPUP_WIDTH - 2); - assert_eq!(inner.height, super::NEW_LINKED_WORKTREE_POPUP_HEIGHT - 2); - assert_eq!(create.y, inner.y + inner.height - 1); - assert_eq!(cancel.y, inner.y + inner.height - 1); - } - - const RENAME_AREA: Rect = Rect { - x: 0, - y: 0, - width: 80, - height: 20, - }; - const WORKTREE_AREA: Rect = Rect { - x: 0, - y: 0, - width: 100, - height: 30, - }; - - /// Reproduces the input row that `render_rename_overlay` lays out: the - /// centred popup, the border inset, then the third row of the vertical - /// split. - fn rename_input_rect(area: Rect) -> Rect { - let popup = super::centered_popup_rect(area, 56, 7).expect("popup fits"); - let inner = Rect::new(popup.x + 1, popup.y + 1, popup.width - 2, popup.height - 2); - Rect::new(inner.x, inner.y + 2, inner.width, 1) - } - - fn rename_overlay_caret_in(mode: Mode, name: &str) -> (Position, Buffer) { - let mut app = AppState::test_new(); - app.mode = mode; - app.name_input = name.into(); - - let mut terminal = Terminal::new(TestBackend::new(RENAME_AREA.width, RENAME_AREA.height)) - .expect("test terminal"); - terminal - .draw(|frame| render_rename_overlay(&app, frame, RENAME_AREA)) - .expect("rename overlay should render"); - let caret = terminal.get_cursor_position().expect("cursor position"); - (caret, terminal.backend().buffer().clone()) - } - - fn rename_overlay_caret(name: &str) -> Position { - rename_overlay_caret_in(Mode::RenameWorkspace, name).0 - } - - fn worktree_overlay_caret(branch: &str) -> Position { - let mut app = AppState::test_new(); - app.name_input = branch.into(); - app.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: "/repo/.worktrees/herdr/foo".into(), - error: None, - creating: false, - }); - - let mut terminal = - Terminal::new(TestBackend::new(WORKTREE_AREA.width, WORKTREE_AREA.height)) - .expect("test terminal"); - terminal - .draw(|frame| render_new_linked_worktree_overlay(&app, frame, WORKTREE_AREA)) - .expect("new worktree overlay should render"); - terminal.get_cursor_position().expect("cursor position") - } - - #[test] - fn rename_overlay_anchors_the_host_cursor_to_the_input_caret() { - let input = rename_input_rect(RENAME_AREA); - - // Without an explicit cursor the frame carries none, the client parks the - // host cursor where the focused pane last reported it, and the IME - // composes there instead of in the dialog. - assert_eq!( - rename_overlay_caret(""), - Position::new(input.x + 1, input.y), - "empty input should put the caret past the one-column left padding" - ); - assert_eq!( - rename_overlay_caret("abcd"), - Position::new(input.x + 5, input.y) - ); - - // The cell under the caret has to be blank: a host terminal draws its - // cursor by inverting that cell, so a glyph there would swallow it. - let (caret, buffer) = rename_overlay_caret_in(Mode::RenameWorkspace, "ab"); - assert_eq!(caret, Position::new(input.x + 3, input.y)); - assert_eq!(buffer[(caret.x, caret.y)].symbol(), " "); - assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "b"); - } - - #[test] - fn rename_overlay_anchors_the_cursor_in_every_rename_mode() { - let input = rename_input_rect(RENAME_AREA); - let expected = Position::new(input.x + 3, input.y); - - for mode in [Mode::RenameWorkspace, Mode::RenameTab, Mode::RenamePane] { - assert_eq!( - rename_overlay_caret_in(mode, "ab").0, - expected, - "{mode:?} should anchor the caret like the other rename modes" - ); - } - } - - #[test] - fn rename_overlay_caret_counts_wide_characters_as_two_columns() { - let input = rename_input_rect(RENAME_AREA); - - // "あい" is two columns per character, so the caret sits two cells further - // right than the two-column "ab". - assert_eq!( - rename_overlay_caret("あい"), - Position::new(input.x + 5, input.y) - ); - assert_eq!( - rename_overlay_caret("aあ"), - Position::new(input.x + 4, input.y) - ); - } - - #[test] - fn rename_overlay_caret_stays_inside_the_input_when_the_name_overflows() { - let input = rename_input_rect(RENAME_AREA); - let last_column = input.right() - 1; - - // The field is 54 columns wide. 51 characters is the last name whose - // caret still lands strictly inside it; from 52 on the unclamped column - // would leave the field and gets pinned to the final cell. - assert_eq!( - rename_overlay_caret(&"a".repeat(51)), - Position::new(input.x + 52, input.y) - ); - assert_eq!( - rename_overlay_caret(&"a".repeat(53)), - Position::new(last_column, input.y) - ); - assert_eq!( - rename_overlay_caret(&"a".repeat(200)), - Position::new(last_column, input.y) - ); - - // The clamped cell has to stay blank as well, or the host cursor would - // sit on a glyph and the IME would compose over it. - let (caret, buffer) = rename_overlay_caret_in(Mode::RenameWorkspace, &"a".repeat(200)); - assert_eq!(caret, Position::new(last_column, input.y)); - assert_eq!(buffer[(caret.x, caret.y)].symbol(), " "); - assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "a"); - } - - #[test] - fn rename_overlay_caret_reaches_the_frame_the_server_sends() { - let input = rename_input_rect(RENAME_AREA); - let mut app = AppState::test_new(); - app.mode = Mode::RenameWorkspace; - app.name_input = "ab".into(); - - // The widget tests above stop at the ratatui frame. This one goes through - // the server's cursor resolution, which is where the bug lived: the frame - // used to leave here with `cursor: None`. - let (_, cursor) = - crate::server::render_stream::render_virtual(&mut app, RENAME_AREA, false); - let cursor = cursor.expect("the modal caret should survive cursor resolution"); - - assert_eq!((cursor.x, cursor.y), (input.x + 3, input.y)); - assert!(cursor.visible); - } - - #[test] - fn new_worktree_overlay_anchors_the_host_cursor_to_the_input_caret() { - let popup = super::new_linked_worktree_inner_rect(WORKTREE_AREA).expect("popup fits"); - let input = Rect::new(popup.x, popup.y + 2, popup.width, 1); - - assert_eq!( - worktree_overlay_caret(""), - Position::new(input.x + 1, input.y) - ); - assert_eq!( - worktree_overlay_caret("ab"), - Position::new(input.x + 3, input.y) - ); - assert_eq!( - worktree_overlay_caret("あい"), - Position::new(input.x + 5, input.y) - ); - } -} diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs deleted file mode 100644 index 3a909b41..00000000 --- a/src/ui/keybind_help.rs +++ /dev/null @@ -1,190 +0,0 @@ -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Paragraph, Wrap}, - Frame, -}; - -use super::release_notes::release_notes_close_button_rect; -use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar}; -use super::widgets::{ - modal_stack_areas, panel_contrast_fg, render_action_button, render_modal_header, - render_modal_shell, -}; -use crate::app::AppState; - -pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> { - let heading_style = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let key_style = Style::default() - .fg(app.palette.mauve) - .add_modifier(Modifier::BOLD); - let label_style = Style::default().fg(app.palette.text); - - let groups = crate::input::filter_keybind_help_groups( - crate::input::keybind_help_groups(&app.keybinds, (app.prefix_code, app.prefix_mods)), - &app.keybind_help.query, - ); - let key_width = groups - .iter() - .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count())) - .max() - .unwrap_or(8); - - let mut lines = Vec::new(); - - if groups.is_empty() { - let message = " no matching keybinds"; - return vec![( - message.chars().count(), - Line::from(Span::styled( - message, - Style::default().fg(app.palette.overlay1), - )), - )]; - } - - for (group, entries) in groups { - lines.push(( - group.len() + 1, - Line::from(vec![Span::styled(format!(" {group}"), heading_style)]), - )); - for (key, label) in entries { - let padded_key = format!(" {:(stack.header); - - render_modal_header(frame, header_rows[0], "keybinds", &app.palette); - render_action_button( - frame, - release_notes_close_button_rect(header_rows[0]), - Some("esc"), - if app.keybind_help.search_focused { - "back" - } else { - "close" - }, - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - let search_line = if app.keybind_help.search_focused { - Line::from(vec![ - Span::styled( - " / ", - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - app.keybind_help.query.as_str(), - Style::default() - .fg(app.palette.text) - .add_modifier(Modifier::BOLD), - ), - ]) - } else { - Line::from(Span::styled( - " press / to filter by command or shortcut", - Style::default().fg(app.palette.overlay0), - )) - }; - frame.render_widget(Paragraph::new(search_line), header_rows[1]); - - let body_area = stack.content; - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: app - .keybind_help_max_scroll() - .saturating_sub(app.keybind_help.scroll) as usize, - max_offset_from_bottom: app.keybind_help_max_scroll() as usize, - viewport_rows: body_area.height.max(1) as usize, - }; - let track = release_notes_scrollbar_rect(body_area, metrics); - let text_area = track - .map(|_| { - Rect::new( - body_area.x, - body_area.y, - body_area.width.saturating_sub(1), - body_area.height, - ) - }) - .unwrap_or(body_area); - - let body = Paragraph::new( - keybind_help_lines(app) - .into_iter() - .map(|(_, line)| line) - .collect::>(), - ) - .wrap(Wrap { trim: false }) - .scroll((app.keybind_help.scroll, 0)); - frame.render_widget(body, text_area); - if let Some(track) = track { - render_scrollbar( - frame, - metrics, - track, - app.palette.overlay0, - app.palette.overlay1, - "▐", - ); - } - - let footer = if app.keybind_help.search_focused { - Line::from(vec![ - Span::styled(" filter ", Style::default().fg(app.palette.overlay0)), - Span::styled("type/backspace", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("clear ", Style::default().fg(app.palette.overlay0)), - Span::styled("ctrl+u", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("↑↓/pgup/pgdn", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("back ", Style::default().fg(app.palette.overlay0)), - Span::styled("esc", Style::default().fg(app.palette.text)), - ]) - } else { - Line::from(vec![ - Span::styled(" search ", Style::default().fg(app.palette.overlay0)), - Span::styled("/", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("j/k/↑↓/pgup/pgdn", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("close ", Style::default().fg(app.palette.overlay0)), - Span::styled("esc/enter", Style::default().fg(app.palette.text)), - ]) - }; - frame.render_widget(Paragraph::new(footer), stack.footer.unwrap_or_default()); -} diff --git a/src/ui/menus.rs b/src/ui/menus.rs deleted file mode 100644 index 763c0ea8..00000000 --- a/src/ui/menus.rs +++ /dev/null @@ -1,315 +0,0 @@ -use ratatui::{ - layout::{Alignment, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, List, ListItem, ListState, Paragraph}, - Frame, -}; - -use super::widgets::{panel_contrast_fg, render_panel_shell}; -use crate::app::AppState; - -fn prefix_rhs_label(bindings: &crate::config::ActionKeybinds) -> String { - bindings - .prefix_rhs_label() - .unwrap_or_else(|| "unset".to_string()) -} - -fn keybind_label(bindings: &crate::config::ActionKeybinds) -> String { - bindings.label().unwrap_or_else(|| "unset".to_string()) -} - -fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui::style::Color) { - frame.render_widget(Clear, area); - let buf = frame.buffer_mut(); - for x in area.x..area.x + area.width { - buf[(x, area.y)].set_style(Style::default().bg(bg)); - } - frame.render_widget(Paragraph::new(line), area); -} - -pub(super) fn render_prefix_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - let mode_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD); - - let workspace_picker = prefix_rhs_label(&app.keybinds.workspace_picker); - let help = prefix_rhs_label(&app.keybinds.help); - let prefix = crate::config::format_key_combo((app.prefix_code, app.prefix_mods)); - - let line = Line::from(vec![ - Span::styled(" PREFIX ", mode_style), - Span::raw(" "), - Span::styled("esc", key), - Span::styled(" cancel ", dim), - Span::styled(prefix, key), - Span::styled(" send prefix ", dim), - Span::styled(workspace_picker, key), - Span::styled(" workspace nav ", dim), - Span::styled(help, key), - Span::styled(" keybinds", dim), - ]); - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); -} - -pub(super) fn render_copy_mode_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - let mode_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD); - - let Some(copy_mode) = app.copy_mode.as_ref() else { - return; - }; - let line = if let Some(prompt) = copy_mode.search.prompt.as_ref() { - let marker = match prompt.direction { - crate::app::state::CopyModeSearchDirection::Forward => "/", - crate::app::state::CopyModeSearchDirection::Backward => "?", - }; - Line::from(vec![ - Span::styled(" COPY ", mode_style), - Span::raw(" "), - Span::styled(marker, key), - Span::styled(prompt.query.clone(), Style::default().fg(app.palette.text)), - Span::styled("█", key), - Span::styled(" enter search esc cancel", dim), - ]) - } else { - let select = if copy_mode.selection.is_some() { - "selecting" - } else { - "select" - }; - let match_status = copy_mode - .search - .current - .map(|current| format!(" {}/{}", current + 1, copy_mode.search.matches.len())) - .or_else(|| (!copy_mode.search.query.is_empty()).then(|| " 0/0".to_string())) - .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") - }; - Line::from(vec![ - Span::styled(" COPY ", mode_style), - Span::raw(" "), - Span::styled("h/j/k/l w/b/e { }", key), - Span::styled(" move ", dim), - Span::styled("/ ?", key), - Span::styled(" search ", dim), - Span::styled("n/N", key), - Span::styled(format!(" repeat{match_status} "), dim), - Span::styled("v/space", key), - Span::styled(format!(" {select} "), dim), - Span::styled("y/enter", key), - Span::styled(" copy ", dim), - Span::styled(exit_keys, key), - Span::styled(exit_label, dim), - ]) - }; - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); -} - -pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - - let mode_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD); - - let kb = &app.keybinds; - let new_tab = prefix_rhs_label(&kb.new_tab); - let split_vertical = prefix_rhs_label(&kb.split_vertical); - let split_horizontal = prefix_rhs_label(&kb.split_horizontal); - let close_pane = prefix_rhs_label(&kb.close_pane); - let zoom = prefix_rhs_label(&kb.zoom); - let resize = prefix_rhs_label(&kb.resize_mode); - let help = prefix_rhs_label(&kb.help); - let settings = prefix_rhs_label(&kb.settings); - let goto = prefix_rhs_label(&kb.goto); - let detach = prefix_rhs_label(&kb.detach); - let workspace_nav = format!( - "{} / {}", - keybind_label(&kb.navigate.workspace_up), - keybind_label(&kb.navigate.workspace_down) - ); - let line = Line::from(vec![ - Span::styled(" NAVIGATE ", mode_style), - Span::raw(" "), - Span::styled("esc", key), - Span::styled(" back ", dim), - Span::styled(workspace_nav, key), - Span::styled(" ws ", dim), - Span::styled("⇥", key), - Span::styled(" pane ", dim), - Span::styled(goto, key), - Span::styled(" navigator ", dim), - Span::styled(new_tab, key), - Span::styled(" new tab ", dim), - Span::styled(split_vertical, key), - Span::styled(" split│ ", dim), - Span::styled(split_horizontal, key), - Span::styled(" split─ ", dim), - Span::styled(close_pane, key), - Span::styled(" close ", dim), - Span::styled(zoom, key), - Span::styled(" zoom ", dim), - Span::styled(resize, key), - Span::styled(" resize ", dim), - Span::styled(help, key), - Span::styled(" keybinds ", dim), - Span::styled(settings, key), - Span::styled(" settings ", dim), - Span::styled(detach, key), - Span::styled(" detach", dim), - ]); - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); - - if app.update_available.is_some() { - let status = Line::from(vec![Span::styled( - " update ready", - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - )]); - let width = 13u16.min(overlay_area.width); - let status_area = Rect::new( - overlay_area.x + overlay_area.width.saturating_sub(width), - overlay_area.y, - width, - overlay_area.height, - ); - frame.render_widget(Clear, status_area); - frame.render_widget( - Paragraph::new(status).alignment(Alignment::Right), - status_area, - ); - } -} - -pub(super) fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) { - let rect = app.global_menu_rect(); - let Some(inner) = render_panel_shell(frame, rect, app.palette.accent, app.palette.panel_bg) - else { - return; - }; - - let items = app.global_menu_labels(); - for (idx, item) in items.iter().enumerate() { - let y = inner.y + idx as u16; - if y >= inner.y + inner.height { - break; - } - let selected = idx == app.global_menu.highlighted; - let rect = Rect::new(inner.x, y, inner.width, 1); - - let selected_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let item_style = if selected { - selected_style - } else { - Style::default().fg(app.palette.text) - }; - let badge_style = if selected { - selected_style - } else { - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD) - }; - - let line = if app.global_menu_item_has_badge(item) { - Line::from(vec![ - Span::styled(" ●", badge_style), - Span::styled(format!(" {item} "), item_style), - ]) - } else { - Line::from(Span::styled(format!(" {item} "), item_style)) - }; - frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), rect); - } -} - -pub(super) fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - - let mode_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.mauve) - .add_modifier(Modifier::BOLD); - - let line = Line::from(vec![ - Span::styled(" RESIZE ", mode_style), - Span::raw(" "), - Span::styled("h/l", key), - Span::styled(" width ", dim), - Span::styled("j/k", key), - Span::styled(" height ", dim), - Span::styled("esc", key), - Span::styled(" done", dim), - ]); - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); -} - -pub(super) fn render_context_menu(app: &AppState, frame: &mut Frame) { - let Some(menu) = &app.context_menu else { - return; - }; - - let p = &app.palette; - let Some(menu_rect) = app.context_menu_rect() else { - return; - }; - let Some(inner) = render_panel_shell(frame, menu_rect, p.accent, p.panel_bg) else { - return; - }; - - let items: Vec = menu - .items() - .iter() - .map(|item| ListItem::new(Line::from(*item))) - .collect(); - let list = List::new(items) - .style(Style::default().fg(p.text)) - .highlight_style( - Style::default() - .bg(p.accent) - .fg(panel_contrast_fg(p)) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(" "); - let mut state = ListState::default().with_selected(Some(menu.list.highlighted)); - frame.render_stateful_widget(list, inner, &mut state); -} diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs deleted file mode 100644 index bdec6551..00000000 --- a/src/ui/mobile.rs +++ /dev/null @@ -1,1626 +0,0 @@ -use ratatui::{ - layout::{Alignment, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, Paragraph}, - Frame, -}; - -use super::sidebar::{ - agent_panel_entries, agent_panel_entries_from, grouped_child_display_label, - next_entry_is_indented_workspace, workspace_list_entries_expanded, AgentPanelEntry, - WorkspaceListEntry, -}; -use super::status::{state_icon, state_icon_symbol}; -use super::text::{display_width_u16, truncate_end}; -use crate::app::state::{Palette, ToastKind, ToastNotification}; -use crate::app::AppState; -use crate::config::StatusIndicatorStyle; -use crate::detect::AgentState; -use crate::layout::PaneId; -use crate::terminal::TerminalRuntimeRegistry; - -const SWITCH_BUTTON_WIDTH: u16 = 10; - -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct MobileHeaderHitAreas { - pub menu: Rect, -} - -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct MobileSwitcherAreas { - pub close: Rect, - pub viewport: Rect, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum MobileSwitcherTarget { - NewWorkspace, - Workspace(usize), - NewTab, - Tab(usize), - Agent { - ws_idx: usize, - tab_idx: usize, - pane_id: PaneId, - }, - Menu(usize), -} - -pub(crate) fn is_mobile_width(area: Rect, threshold: u16) -> bool { - area.width > 0 && area.width <= threshold -} - -pub(crate) fn compute_mobile_header_hit_areas(_app: &AppState, area: Rect) -> MobileHeaderHitAreas { - if area.width == 0 || area.height == 0 { - return MobileHeaderHitAreas::default(); - } - - let width = SWITCH_BUTTON_WIDTH.min(area.width); - let switch = Rect::new( - area.x + area.width.saturating_sub(width), - area.y, - width, - area.height, - ); - - MobileHeaderHitAreas { menu: switch } -} - -pub(crate) fn mobile_switcher_areas(app: &AppState) -> MobileSwitcherAreas { - let screen = mobile_screen_rect(app); - if screen.width == 0 || screen.height <= 2 { - return MobileSwitcherAreas::default(); - } - - let header_h = screen.height.min(2); - let close_w = 10u16.min(screen.width); - let close = Rect::new( - screen.x + screen.width.saturating_sub(close_w), - screen.y, - close_w, - header_h, - ); - let viewport = Rect::new( - screen.x, - screen.y + header_h + 1, - screen.width, - screen.height.saturating_sub(header_h + 1), - ); - - MobileSwitcherAreas { close, viewport } -} - -pub(crate) fn mobile_switcher_max_scroll_for_height(app: &AppState, viewport_height: u16) -> usize { - mobile_switcher_content_height(app).saturating_sub(viewport_height as usize) -} - -/// Doc-row height of the agents section. An active query keeps its title and an -/// empty-state row visible even when no agents match. -fn mobile_agents_block_height(app: &AppState) -> usize { - let count = agent_panel_entries(app).len(); - if count == 0 { - usize::from(app.agent_view_override.is_some()) * 2 - } else { - 1 + count * 2 - } -} - -pub(crate) fn mobile_switcher_workspace_doc_range( - app: &AppState, - idx: usize, -) -> std::ops::Range { - // Spaces render in grouped order, so a workspace's row position is its index - // in the entry list, not its raw array index. - let pos = workspace_list_entries_expanded(app) - .iter() - .position(|WorkspaceListEntry::Workspace { ws_idx, .. }| *ws_idx == idx) - .unwrap_or(idx); - // spaces sit after the agents block, then a title + "new workspace" row. - let start = mobile_agents_block_height(app) + 2 + pos * 2; - start..start + 2 -} - -pub(crate) fn mobile_switcher_max_scroll(app: &AppState) -> usize { - mobile_switcher_max_scroll_for_height(app, mobile_switcher_areas(app).viewport.height) -} - -pub(crate) fn mobile_switcher_target_at( - app: &AppState, - col: u16, - row: u16, -) -> Option { - let areas = mobile_switcher_areas(app); - let content = inset_for_left_scrollbar(areas.viewport); - if !rect_contains(content, col, row) { - return None; - } - - let scroll = app - .mobile_switcher_scroll - .min(mobile_switcher_max_scroll_for_height( - app, - areas.viewport.height, - )); - let doc_row = scroll.saturating_add(row.saturating_sub(areas.viewport.y) as usize); - let mut cursor = 0usize; - - // Agents lead the switcher: the primary job is switching between running - // agents. Spaces/tabs/create actions follow for navigation and management. - let agents = agent_panel_entries(app); - if !agents.is_empty() || app.agent_view_override.is_some() { - cursor += 1; // agents title - if agents.is_empty() { - cursor += 1; // active-query empty state - } else { - let agents_end = cursor + agents.len() * 2; - if doc_row >= cursor && doc_row < agents_end { - let idx = (doc_row - cursor) / 2; - return agents.get(idx).map(|entry| MobileSwitcherTarget::Agent { - ws_idx: entry.ws_idx, - tab_idx: entry.tab_idx, - pane_id: entry.pane_id, - }); - } - cursor = agents_end; - } - } - - cursor += 1; // spaces title - if doc_row == cursor { - return Some(MobileSwitcherTarget::NewWorkspace); - } - cursor += 1; - // Spaces render in grouped (worktree-tree) order, which differs from raw - // array order, so map the clicked row to the entry's real workspace index. - let space_entries = workspace_list_entries_expanded(app); - let spaces_end = cursor + space_entries.len() * 2; - if doc_row >= cursor && doc_row < spaces_end { - let entry_idx = (doc_row - cursor) / 2; - return space_entries.get(entry_idx).map( - |WorkspaceListEntry::Workspace { ws_idx, .. }| MobileSwitcherTarget::Workspace(*ws_idx), - ); - } - cursor = spaces_end; - - if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) { - cursor += 1; // tabs title - if doc_row == cursor { - return Some(MobileSwitcherTarget::NewTab); - } - cursor += 1; - let tabs_end = cursor + ws.tabs.len(); - if doc_row >= cursor && doc_row < tabs_end { - return Some(MobileSwitcherTarget::Tab(doc_row - cursor)); - } - cursor = tabs_end; - } - - cursor += 1; // menu title - let menu_idx = doc_row.checked_sub(cursor)?; - (menu_idx < app.global_menu_labels().len()).then_some(MobileSwitcherTarget::Menu(menu_idx)) -} - -pub(crate) fn render_mobile_header( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let p = &app.palette; - fill_rect(frame, area, Style::default().bg(p.panel_bg)); - - let switch = app.view.mobile_menu_hit_area; - let status_w = switch.x.saturating_sub(area.x).saturating_sub(1); - let status = Rect::new(area.x, area.y, status_w, area.height); - - render_header_status(app, terminal_runtimes, frame, status); - render_switch_button(app, frame, switch); -} - -pub(crate) fn mobile_toast_banner_rect(area: Rect, offset_for_warning: bool) -> Rect { - if area.width == 0 || area.height == 0 { - return Rect::default(); - } - - let y = area.y - + area - .height - .saturating_sub(1 + if offset_for_warning { 1 } else { 0 }); - Rect::new(area.x, y, area.width, 1) -} - -pub(crate) fn render_mobile_toast_banner( - frame: &mut Frame, - area: Rect, - toast: &ToastNotification, - offset_for_warning: bool, - p: &Palette, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let dot_color = match toast.kind { - ToastKind::NeedsAttention => p.red, - ToastKind::Finished => p.blue, - ToastKind::UpdateInstalled => p.accent, - }; - let banner = mobile_toast_banner_rect(area, offset_for_warning); - let bg = p.surface0; - - frame.render_widget(Clear, banner); - fill_rect(frame, banner, Style::default().bg(bg)); - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" ", Style::default().bg(bg)), - Span::styled("●", Style::default().fg(dot_color).bg(bg)), - Span::styled(" ", Style::default().bg(bg)), - Span::styled( - mobile_toast_title(toast), - Style::default() - .fg(p.text) - .bg(bg) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" · ", Style::default().fg(p.overlay0).bg(bg)), - Span::styled(&toast.context, Style::default().fg(p.overlay0).bg(bg)), - ])), - banner, - ); -} - -pub(crate) fn render_mobile_panel( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let p = &app.palette; - frame.render_widget(Clear, area); - fill_rect(frame, area, Style::default().bg(p.panel_bg)); - - let areas = mobile_switcher_areas(app); - frame.render_widget( - Paragraph::new(" switch").style( - Style::default() - .fg(p.text) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD), - ), - Rect::new(area.x, area.y, areas.close.x.saturating_sub(area.x), 1), - ); - render_close_button(app, frame, areas.close); - - if area.height > areas.close.height { - draw_horizontal_rule( - frame, - Rect::new(area.x, area.y + areas.close.height, area.width, 1), - p, - ); - } - - render_mobile_switcher_content(app, terminal_runtimes, frame, areas.viewport); -} - -fn render_header_status( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.width == 0 || area.height == 0 { - return; - } - let p = &app.palette; - let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) else { - frame.render_widget(Paragraph::new(" no workspace"), area); - return; - }; - - let (state, seen) = ws.aggregate_state(&app.terminals); - let (dot, dot_style) = state_icon(state, seen, app.status_indicators, p); - let tab_label = mobile_tab_status(ws); - let row1 = Rect::new(area.x, area.y, area.width, 1); - let tab_w = display_width_u16(&tab_label) - .saturating_add(1) - .min(area.width); - let name_w = area.width.saturating_sub(tab_w); - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::raw(" "), - Span::styled(dot, dot_style.bg(p.panel_bg)), - Span::raw(" "), - Span::styled( - truncate_end( - &ws.display_name_from(&app.terminals, terminal_runtimes), - name_w.saturating_sub(4) as usize, - ), - Style::default() - .fg(p.text) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD), - ), - ])), - Rect::new(row1.x, row1.y, name_w, 1), - ); - frame.render_widget( - Paragraph::new(tab_label) - .style(Style::default().fg(p.overlay1).bg(p.panel_bg)) - .alignment(Alignment::Right), - Rect::new(row1.x + name_w, row1.y, tab_w, 1), - ); - - if area.height > 1 { - frame.render_widget( - Paragraph::new(agent_summary_line(app, p, area.width)), - Rect::new(area.x, area.y + 1, area.width, 1), - ); - } -} - -fn mobile_tab_status(ws: &crate::workspace::Workspace) -> String { - let tab_label = ws - .tab_display_name(ws.active_tab) - .unwrap_or_else(|| (ws.active_tab + 1).to_string()); - if ws.tabs.len() <= 1 { - format!("tab {tab_label}") - } else { - format!("tab {tab_label} · {}/{}", ws.active_tab + 1, ws.tabs.len()) - } -} - -fn render_switch_button(app: &AppState, frame: &mut Frame, area: Rect) { - if area.width == 0 || area.height == 0 { - return; - } - let p = &app.palette; - fill_rect(frame, area, Style::default().bg(p.surface0)); - for y in area.y..area.y + area.height { - frame.buffer_mut()[(area.x, y)] - .set_symbol("│") - .set_style(Style::default().fg(p.surface_dim).bg(p.surface0)); - } - let label_y = if area.height > 1 { area.y + 1 } else { area.y }; - frame.render_widget( - Paragraph::new("switch") - .style( - Style::default() - .fg(p.text) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center), - Rect::new(area.x + 1, label_y, area.width.saturating_sub(1), 1), - ); - - // Attention badge: a blocked agent anywhere makes the button itself read as - // "tap me" without the user reading the summary row. - if global_agent_counts(app).blocked > 0 { - let bx = area.x + area.width.saturating_sub(1); - let (symbol, style) = state_icon(AgentState::Blocked, true, app.status_indicators, p); - frame.buffer_mut()[(bx, area.y)] - .set_symbol(symbol) - .set_style(style.bg(p.surface0)); - } -} - -fn render_close_button(app: &AppState, frame: &mut Frame, area: Rect) { - if area.width == 0 || area.height == 0 { - return; - } - let p = &app.palette; - fill_rect(frame, area, Style::default().bg(p.surface0)); - for y in area.y..area.y + area.height { - frame.buffer_mut()[(area.x, y)] - .set_symbol("│") - .set_style(Style::default().fg(p.surface_dim).bg(p.surface0)); - } - frame.render_widget( - Paragraph::new("close") - .style( - Style::default() - .fg(p.overlay1) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center), - Rect::new(area.x + 1, area.y, area.width.saturating_sub(1), 1), - ); - if area.height > 1 { - frame.render_widget( - Paragraph::new("×") - .style( - Style::default() - .fg(p.text) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center), - Rect::new(area.x + 1, area.y + 1, area.width.saturating_sub(1), 1), - ); - } -} - -fn mobile_switcher_content_height(app: &AppState) -> usize { - // Derive spaces height from the same entry list the render/hit-test use so - // the three never disagree. - let spaces_h = 2 + workspace_list_entries_expanded(app).len() * 2; - let tabs_h = app - .active - .and_then(|idx| app.workspaces.get(idx)) - .map(|ws| 2 + ws.tabs.len()) - .unwrap_or(0); - let agents_h = mobile_agents_block_height(app); - let menu_h = 1 + app.global_menu_labels().len(); - spaces_h + tabs_h + agents_h + menu_h -} - -fn render_mobile_switcher_content( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - viewport: Rect, -) { - if viewport.width == 0 || viewport.height == 0 { - return; - } - - let p = &app.palette; - let total_height = mobile_switcher_content_height(app); - render_left_scrollbar( - frame, - viewport, - total_height, - viewport.height as usize, - app.mobile_switcher_scroll, - p, - ); - let content = inset_for_left_scrollbar(viewport); - if content == Rect::default() { - return; - } - - let mut doc_y = 0usize; - - let entries = agent_panel_entries_from(app, terminal_runtimes); - if !entries.is_empty() || app.agent_view_override.is_some() { - let focused_agent = app.active.and_then(|ws_idx| { - let ws = app.workspaces.get(ws_idx)?; - ws.focused_pane_id() - .map(|pane_id| (ws_idx, ws.active_tab, pane_id)) - }); - let title = app - .agent_view_override - .as_ref() - .map(|view| format!("agents · {}", view.label.as_deref().unwrap_or("filtered"))) - .unwrap_or_else(|| "agents".to_string()); - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - &title, - p, - ); - doc_y += 1; - if entries.is_empty() { - render_one_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - ratatui::style::Color::Reset, - Line::from(Span::styled( - " no matching agents", - Style::default().fg(p.overlay0).add_modifier(Modifier::DIM), - )), - ); - doc_y += 1; - } - for entry in &entries { - let active = focused_agent.is_some_and(|(ws_idx, tab_idx, pane_id)| { - entry.ws_idx == ws_idx && entry.tab_idx == tab_idx && entry.pane_id == pane_id - }); - let bg = mobile_item_bg(false, active, p); - let (icon, icon_style) = state_icon(entry.state, entry.seen, app.status_indicators, p); - let title = Line::from(vec![ - Span::styled(" ", Style::default().bg(bg)), - Span::styled(icon, icon_style.bg(bg)), - Span::styled(" ", Style::default().bg(bg)), - Span::styled( - truncate_end( - &entry.primary_label, - content.width.saturating_sub(5) as usize, - ), - Style::default() - .fg(p.text) - .bg(bg) - .add_modifier(Modifier::BOLD), - ), - ]); - let detail = mobile_agent_detail(entry); - render_two_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - bg, - title, - truncate_end(&detail, content.width as usize), - p.overlay0, - ); - doc_y += 2; - } - } - - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "spaces", - p, - ); - doc_y += 1; - render_action_row_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "+ new workspace", - p, - ); - doc_y += 1; - let space_entries = workspace_list_entries_expanded(app); - for (entry_idx, WorkspaceListEntry::Workspace { ws_idx, indented }) in - space_entries.iter().enumerate() - { - let Some(ws) = app.workspaces.get(*ws_idx) else { - continue; - }; - let active = Some(*ws_idx) == app.active; - let selected = *ws_idx == app.selected; - let bg = mobile_item_bg(selected, active, p); - let (state, seen) = ws.aggregate_state(&app.terminals); - let (dot, dot_style) = state_icon(state, seen, app.status_indicators, p); - - let mut title_spans = vec![Span::styled(" ", Style::default().bg(bg))]; - // Worktrees of the same space render as branches off their parent, so a - // child gets an L/T connector on its name row and a matching vertical - // continuation on its detail row. - let detail_prefix = if *indented { - let last_child = !next_entry_is_indented_workspace(&space_entries, entry_idx); - title_spans.push(Span::styled( - if last_child { "└─ " } else { "├─ " }, - Style::default().fg(p.overlay0).bg(bg), - )); - if last_child { - " " - } else { - " │ " - } - } else { - " " - }; - - title_spans.push(Span::styled(dot, dot_style.bg(bg))); - title_spans.push(Span::styled(" ", Style::default().bg(bg))); - let raw_label = ws.display_name_from(&app.terminals, terminal_runtimes); - let name = if *indented { - grouped_child_display_label( - &raw_label, - ws.branch().as_deref(), - ws.custom_name.is_some(), - ) - } else { - raw_label - }; - let name_budget = content.width.saturating_sub(if *indented { 8 } else { 5 }) as usize; - title_spans.push(Span::styled( - truncate_end(&name, name_budget), - Style::default() - .fg(p.text) - .bg(bg) - .add_modifier(Modifier::BOLD), - )); - - let detail = format!( - "{detail_prefix}{} · {}", - ws.branch().unwrap_or_else(|| "shell".into()), - mobile_tab_status(ws) - ); - render_two_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - bg, - Line::from(title_spans), - truncate_end(&detail, content.width as usize), - p.overlay0, - ); - doc_y += 2; - } - - if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) { - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "tabs", - p, - ); - doc_y += 1; - render_action_row_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "+ new tab", - p, - ); - doc_y += 1; - for (idx, tab) in ws.tabs.iter().enumerate() { - let active = idx == ws.active_tab; - let bg = mobile_item_bg(false, active, p); - let display_name = ws - .tab_display_name(idx) - .unwrap_or_else(|| (idx + 1).to_string()); - let label = if tab.is_auto_named() { - format!("tab {display_name}") - } else { - format!("{} · {display_name}", idx + 1) - }; - let title = Line::from(vec![ - Span::styled(" ", Style::default().bg(bg)), - Span::styled( - truncate_end(&label, content.width.saturating_sub(3) as usize), - Style::default() - .fg(p.text) - .bg(bg) - .add_modifier(Modifier::BOLD), - ), - ]); - render_one_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - bg, - title, - ); - doc_y += 1; - } - } - - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "menu", - p, - ); - doc_y += 1; - for label in app.global_menu_labels() { - if let Some(y) = visible_y(viewport, app.mobile_switcher_scroll, doc_y) { - frame.render_widget( - Paragraph::new(format!(" {label}")) - .style(Style::default().fg(p.overlay1).bg(p.panel_bg)), - Rect::new(content.x, y, content.width, 1), - ); - } - doc_y += 1; - } -} - -fn mobile_agent_detail(entry: &AgentPanelEntry) -> String { - let mut parts = Vec::new(); - if let Some(tab_label) = entry.primary_tab_label.as_deref() { - parts.push(tab_label.to_string()); - } - let status = entry - .state_labels - .get(super::sidebar::agent_panel_status_key( - entry.state, - entry.seen, - )) - .cloned() - .unwrap_or_else(|| super::status::state_label(entry.state, entry.seen).to_string()); - parts.push(status); - if let Some(agent_label) = entry.agent_label.as_deref() { - parts.push(agent_label.to_string()); - } - format!(" {}", parts.join(" · ")) -} - -fn render_section_title_at( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - scroll: usize, - title: &str, - p: &Palette, -) { - let Some(y) = visible_y(viewport, scroll, doc_y) else { - return; - }; - render_section_title( - frame, - Rect::new(content.x, y, content.width.saturating_sub(1), 1), - title, - p, - ); -} - -fn render_action_row_at( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - scroll: usize, - label: &str, - p: &Palette, -) { - let Some(y) = visible_y(viewport, scroll, doc_y) else { - return; - }; - render_action_row(frame, Rect::new(content.x, y, content.width, 1), label, p); -} - -fn render_one_line_item( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - scroll: usize, - bg: ratatui::style::Color, - title: Line<'_>, -) { - fill_visible_doc_rect( - frame, - viewport, - content, - doc_y, - 1, - Style::default().bg(bg), - scroll, - ); - if let Some(y) = visible_y(viewport, scroll, doc_y) { - frame.render_widget( - Paragraph::new(title), - Rect::new(content.x, y, content.width, 1), - ); - } -} - -fn render_two_line_item( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - scroll: usize, - bg: ratatui::style::Color, - title: Line<'_>, - detail: String, - detail_fg: ratatui::style::Color, -) { - fill_visible_doc_rect( - frame, - viewport, - content, - doc_y, - 2, - Style::default().bg(bg), - scroll, - ); - if let Some(y) = visible_y(viewport, scroll, doc_y) { - frame.render_widget( - Paragraph::new(title), - Rect::new(content.x, y, content.width, 1), - ); - } - if let Some(y) = visible_y(viewport, scroll, doc_y + 1) { - frame.render_widget( - Paragraph::new(detail).style(Style::default().fg(detail_fg).bg(bg)), - Rect::new(content.x, y, content.width, 1), - ); - } -} - -fn visible_y(viewport: Rect, scroll: usize, doc_y: usize) -> Option { - let offset = doc_y.checked_sub(scroll)?; - (offset < viewport.height as usize).then_some(viewport.y + offset as u16) -} - -fn fill_visible_doc_rect( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - height: usize, - style: Style, - scroll: usize, -) { - for offset in 0..height { - if let Some(y) = visible_y(viewport, scroll, doc_y + offset) { - fill_rect(frame, Rect::new(content.x, y, content.width, 1), style); - } - } -} - -fn mobile_item_bg(selected: bool, active: bool, p: &Palette) -> ratatui::style::Color { - if selected { - p.surface0 - } else if active { - p.surface_dim - } else { - p.panel_bg - } -} - -fn inset_for_left_scrollbar(area: Rect) -> Rect { - if area.width <= 1 { - return Rect::default(); - } - Rect::new(area.x + 1, area.y, area.width - 1, area.height) -} - -fn render_left_scrollbar( - frame: &mut Frame, - area: Rect, - total_rows: usize, - visible_rows: usize, - scroll: usize, - p: &Palette, -) { - if area.width == 0 || area.height == 0 || visible_rows == 0 || total_rows <= visible_rows { - return; - } - - let track = Rect::new(area.x, area.y, 1, area.height); - let max_scroll = total_rows.saturating_sub(visible_rows); - let thumb_len = ((track.height as usize * visible_rows).div_ceil(total_rows)) - .max(1) - .min(track.height as usize) as u16; - let travel = track.height.saturating_sub(thumb_len); - let thumb_top = track.y + ((travel as usize * scroll.min(max_scroll)) / max_scroll) as u16; - - for y in track.y..track.y + track.height { - let is_thumb = y >= thumb_top && y < thumb_top + thumb_len; - frame.buffer_mut()[(track.x, y)] - .set_symbol(if is_thumb { "▌" } else { "│" }) - .set_style( - Style::default() - .fg(if is_thumb { p.accent } else { p.surface_dim }) - .bg(p.panel_bg), - ); - } -} - -fn render_section_title(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { - frame.render_widget( - Paragraph::new(format!(" {title} ")).style( - Style::default() - .fg(p.overlay1) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), - ), - Rect::new(area.x, area.y, area.width, 1), - ); -} - -fn render_action_row(frame: &mut Frame, area: Rect, label: &str, p: &Palette) { - if area.width == 0 || area.height == 0 { - return; - } - frame.render_widget( - Paragraph::new(format!(" {label}")).style( - Style::default() - .fg(p.accent) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD), - ), - area, - ); -} - -fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { - rect.width > 0 - && rect.height > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height -} - -fn mobile_screen_rect(app: &AppState) -> Rect { - let header = app.view.mobile_header_rect; - let terminal = app.view.terminal_area; - let x = header.x.min(terminal.x); - let y = header.y.min(terminal.y); - let right = (header.x + header.width).max(terminal.x + terminal.width); - let bottom = (header.y + header.height).max(terminal.y + terminal.height); - Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) -} - -/// Agent state counts across every workspace. The mobile header is global on -/// purpose: while you stare at one terminal, a blocked agent anywhere should -/// still surface. -#[derive(Debug, Default, Clone, Copy)] -struct GlobalAgentCounts { - blocked: usize, - done: usize, - working: usize, - idle: usize, -} - -impl GlobalAgentCounts { - fn total(&self) -> usize { - self.blocked + self.done + self.working + self.idle - } - - fn any_pending(&self) -> bool { - self.blocked > 0 || self.done > 0 || self.working > 0 - } -} - -fn global_agent_counts(app: &AppState) -> GlobalAgentCounts { - let mut counts = GlobalAgentCounts::default(); - for entry in crate::ui::all_agent_panel_entries(app) { - match (entry.state, entry.seen) { - (AgentState::Blocked, _) => counts.blocked += 1, - (AgentState::Idle, false) => counts.done += 1, - (AgentState::Working, _) => counts.working += 1, - (AgentState::Idle, true) => counts.idle += 1, - (AgentState::Unknown, _) => {} - } - } - counts -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SummaryTone { - Blocked, - Done, - Working, - Idle, - Muted, -} - -/// Ordered, non-zero breakdown for the header roll-up: attention states lead -/// (blocked → done → working → idle). Pure so it can be unit-tested. -fn agent_summary_segments( - counts: GlobalAgentCounts, - indicator_style: StatusIndicatorStyle, -) -> Vec<(String, SummaryTone)> { - if counts.total() == 0 { - return vec![("no agents".to_string(), SummaryTone::Muted)]; - } - if !counts.any_pending() { - return vec![("all idle".to_string(), SummaryTone::Muted)]; - } - let mut segments = Vec::new(); - if counts.blocked > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Blocked, - true, - Some("◉"), - counts.blocked, - "blocked", - ), - SummaryTone::Blocked, - )); - } - if counts.done > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Idle, - false, - Some("●"), - counts.done, - "done", - ), - SummaryTone::Done, - )); - } - if counts.working > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Working, - true, - None, - counts.working, - "working", - ), - SummaryTone::Working, - )); - } - if counts.idle > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Idle, - true, - None, - counts.idle, - "idle", - ), - SummaryTone::Idle, - )); - } - segments -} - -fn agent_summary_text( - indicator_style: StatusIndicatorStyle, - state: AgentState, - seen: bool, - dot_style_symbol: Option<&str>, - count: usize, - label: &str, -) -> String { - let symbol = match indicator_style { - StatusIndicatorStyle::Dots => dot_style_symbol, - StatusIndicatorStyle::Symbols => Some(state_icon_symbol(state, seen, indicator_style)), - }; - match symbol { - Some(symbol) => format!("{symbol} {count} {label}"), - None => format!("{count} {label}"), - } -} - -/// Greedily keep the most-urgent segments that fit `max_width` (counting the -/// leading space and " · " separators) and report whether any were dropped. -/// Segments are ordered by urgency, so the dropped tail is always the least -/// important state. -fn fit_summary_segments( - segments: Vec<(String, SummaryTone)>, - max_width: usize, -) -> (Vec<(String, SummaryTone)>, bool) { - let mut shown = Vec::new(); - let mut used = 1usize; // leading space - for (idx, segment) in segments.iter().enumerate() { - let sep = if idx > 0 { 3 } else { 0 }; // " · " - let seg_w = segment.0.chars().count(); - if used + sep + seg_w > max_width { - break; - } - used += sep + seg_w; - shown.push(segment.clone()); - } - let truncated = shown.len() < segments.len(); - (shown, truncated) -} - -fn agent_summary_line(app: &AppState, p: &Palette, max_width: u16) -> Line<'static> { - let segments = agent_summary_segments(global_agent_counts(app), app.status_indicators); - let (shown, truncated) = fit_summary_segments(segments, max_width as usize); - - let mut spans = vec![Span::styled(" ", Style::default().bg(p.panel_bg))]; - let mut used = 1usize; - for (idx, (text, tone)) in shown.into_iter().enumerate() { - if idx > 0 { - spans.push(Span::styled( - " · ", - Style::default().fg(p.overlay0).bg(p.panel_bg), - )); - used += 3; - } - // Only the leading (most urgent) segment keeps its state color; the - // rest stay dim so the urgent count is the loud thing. - let style = if idx == 0 { - let color = match tone { - SummaryTone::Blocked => p.red, - SummaryTone::Done => p.blue, - SummaryTone::Working => p.yellow, - SummaryTone::Idle | SummaryTone::Muted => p.overlay1, - }; - let style = Style::default().fg(color).bg(p.panel_bg); - if tone == SummaryTone::Muted { - style - } else { - style.add_modifier(Modifier::BOLD) - } - } else { - Style::default().fg(p.overlay1).bg(p.panel_bg) - }; - used += text.chars().count(); - spans.push(Span::styled(text, style)); - } - if truncated && used + 2 <= max_width as usize { - spans.push(Span::styled( - " …", - Style::default().fg(p.overlay0).bg(p.panel_bg), - )); - } - Line::from(spans) -} - -fn mobile_toast_title(toast: &ToastNotification) -> String { - match toast.kind { - ToastKind::NeedsAttention => toast - .title - .strip_suffix(" needs attention") - .map(|agent| format!("{agent} waiting")) - .unwrap_or_else(|| toast.title.clone()), - ToastKind::Finished => toast - .title - .strip_suffix(" finished") - .map(|agent| format!("{agent} done")) - .unwrap_or_else(|| toast.title.clone()), - ToastKind::UpdateInstalled => "update ready".to_string(), - } -} - -fn fill_rect(frame: &mut Frame, area: Rect, style: Style) { - if area.width == 0 || area.height == 0 { - return; - } - let buf = frame.buffer_mut(); - for y in area.y..area.y + area.height { - for x in area.x..area.x + area.width { - buf[(x, y)].set_symbol(" "); - buf[(x, y)].set_style(style); - } - } -} - -fn draw_horizontal_rule(frame: &mut Frame, area: Rect, p: &Palette) { - if area.width == 0 || area.height == 0 { - return; - } - let buf = frame.buffer_mut(); - for x in area.x..area.x + area.width { - buf[(x, area.y)] - .set_symbol("─") - .set_style(Style::default().fg(p.surface_dim).bg(p.panel_bg)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn agent_entry(primary_tab_label: Option<&str>, agent_label: Option<&str>) -> AgentPanelEntry { - AgentPanelEntry { - ws_idx: 0, - tab_idx: 0, - pane_id: PaneId::from_raw(1), - primary_label: "herdr".into(), - primary_tab_label: primary_tab_label.map(str::to_string), - pane_label: None, - terminal_title: None, - terminal_title_stripped: None, - agent_label: agent_label.map(str::to_string), - agent_kind_label: agent_label.map(str::to_string), - agent: agent_label.and_then(crate::detect::parse_agent_label), - state: AgentState::Idle, - seen: true, - last_agent_state_change_seq: None, - state_labels: std::collections::HashMap::new(), - tokens: std::collections::HashMap::new(), - } - } - - #[test] - fn global_agent_counts_ignore_active_agent_view_filter() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - crate::workspace::Workspace::test_new("blocked"), - crate::workspace::Workspace::test_new("working"), - ]; - app.ensure_test_terminals(); - for (ws_idx, state) in [(0, AgentState::Blocked), (1, AgentState::Working)] { - let pane_id = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(crate::detect::Agent::Claude); - terminal.state = state; - } - app.agent_view_override = Some(crate::api::schema::AgentViewSetParams { - source: "example.views".to_string(), - label: None, - filter: Some(crate::api::schema::AgentViewFilter::Eq { - field: crate::api::schema::AgentViewField::Builtin( - crate::api::schema::AgentViewBuiltinField::Status, - ), - value: crate::api::schema::AgentViewValue::String("working".to_string()), - }), - sort: Vec::new(), - }); - - let counts = global_agent_counts(&app); - assert_eq!(counts.blocked, 1); - assert_eq!(counts.working, 1); - } - - #[test] - fn agent_summary_leads_with_attention_states_in_priority_order() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let segments = agent_summary_segments(counts, StatusIndicatorStyle::Dots); - let labels: Vec<&str> = segments.iter().map(|(text, _)| text.as_str()).collect(); - assert_eq!( - labels, - vec!["◉ 2 blocked", "● 1 done", "2 working", "1 idle"] - ); - assert_eq!(segments[0].1, SummaryTone::Blocked); - } - - #[test] - fn distinct_agent_summary_uses_configured_symbols_for_every_state() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let labels: Vec = agent_summary_segments(counts, StatusIndicatorStyle::Symbols) - .into_iter() - .map(|(text, _)| text) - .collect(); - assert_eq!( - labels, - ["× 2 blocked", "✓ 1 done", "◐ 2 working", "○ 1 idle"] - ); - } - - #[test] - fn distinct_status_style_updates_mobile_blocked_badge() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![crate::workspace::Workspace::test_new("blocked")]; - app.ensure_test_terminals(); - app.status_indicators = StatusIndicatorStyle::Symbols; - let pane_id = app.workspaces[0].tabs[0].root_pane; - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal_state = app.terminals.get_mut(&terminal_id).unwrap(); - terminal_state.detected_agent = Some(crate::detect::Agent::Claude); - terminal_state.state = AgentState::Blocked; - - let area = Rect::new(0, 0, 12, 2); - let mut terminal = - ratatui::Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height)) - .unwrap(); - terminal - .draw(|frame| render_switch_button(&app, frame, area)) - .unwrap(); - - assert_eq!( - terminal.backend().buffer()[(area.width - 1, 0)].symbol(), - "×" - ); - } - - #[test] - fn agent_summary_hides_empty_categories() { - let counts = GlobalAgentCounts { - done: 1, - working: 2, - ..Default::default() - }; - let labels: Vec = agent_summary_segments(counts, StatusIndicatorStyle::Dots) - .into_iter() - .map(|(text, _)| text) - .collect(); - assert_eq!( - labels, - vec!["● 1 done".to_string(), "2 working".to_string()] - ); - } - - #[test] - fn agent_summary_collapses_to_all_idle_without_attention() { - let counts = GlobalAgentCounts { - idle: 3, - ..Default::default() - }; - assert_eq!( - agent_summary_segments(counts, StatusIndicatorStyle::Dots), - vec![("all idle".to_string(), SummaryTone::Muted)] - ); - } - - #[test] - fn agent_summary_drops_least_urgent_segments_when_narrow() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let (shown, truncated) = fit_summary_segments( - agent_summary_segments(counts, StatusIndicatorStyle::Dots), - 24, - ); - let labels: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect(); - assert_eq!(labels, vec!["◉ 2 blocked", "● 1 done"]); - assert!(truncated); - } - - #[test] - fn agent_summary_keeps_all_segments_when_wide_enough() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let (shown, truncated) = fit_summary_segments( - agent_summary_segments(counts, StatusIndicatorStyle::Dots), - 60, - ); - assert_eq!(shown.len(), 4); - assert!(!truncated); - } - - #[test] - fn agent_summary_reports_no_agents_when_empty() { - assert_eq!( - agent_summary_segments(GlobalAgentCounts::default(), StatusIndicatorStyle::Dots,), - vec![("no agents".to_string(), SummaryTone::Muted)] - ); - } - - #[test] - fn switcher_leads_with_agents_and_shifts_spaces_below() { - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = crate::workspace::Workspace::test_new("agents-first"); - workspace.test_add_tab(None); // two tabs -> two agent panes - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - for terminal in app.terminals.values_mut() { - terminal.agent_name = Some("pi".to_string()); - terminal.state = AgentState::Working; - } - app.active = Some(0); - app.selected = 0; - app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); - app.view.terminal_area = Rect::new(0, 2, 40, 18); - - assert_eq!(agent_panel_entries(&app).len(), 2); - // agents title (1) + 2 agents * 2 rows = 5, then spaces title + "new - // workspace" (2) before the first workspace ribbon at doc row 7. - assert_eq!(mobile_switcher_workspace_doc_range(&app, 0).start, 7); - - let viewport = mobile_switcher_areas(&app).viewport; - app.mobile_switcher_scroll = 100; - let agent_hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 1); - assert!(matches!( - agent_hit, - Some(MobileSwitcherTarget::Agent { .. }) - )); - let workspace_hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 7); - assert_eq!(workspace_hit, Some(MobileSwitcherTarget::Workspace(0))); - } - - fn worktree_workspace(name: &str, key: &str, linked: bool) -> crate::workspace::Workspace { - let mut ws = crate::workspace::Workspace::test_new(name); - ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - checkout_path: std::path::PathBuf::from(format!("/repo/{name}")), - is_linked_worktree: linked, - }); - ws - } - - #[test] - fn switcher_spaces_follow_grouped_worktree_order() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![ - worktree_workspace("main", "repo-key", false), - crate::workspace::Workspace::test_new("other"), - worktree_workspace("feature", "repo-key", true), - ]; - app.active = Some(0); - app.selected = 0; - app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); - app.view.terminal_area = Rect::new(0, 2, 40, 18); - - // Grouped order pulls the worktree (idx 2) up under its parent (idx 0), - // ahead of the unrelated "other" workspace (idx 1): rows are main, - // feature, other. - assert_eq!(mobile_switcher_workspace_doc_range(&app, 2).start, 4); - assert_eq!(mobile_switcher_workspace_doc_range(&app, 1).start, 6); - - let viewport = mobile_switcher_areas(&app).viewport; - // The second space row on screen is the worktree, not workspaces[1]. - let hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 4); - assert_eq!(hit, Some(MobileSwitcherTarget::Workspace(2))); - - // Mobile ignores collapse: even with the space folded on desktop, the - // worktree child still renders in the same position. - app.collapsed_space_keys.insert("repo-key".to_string()); - assert_eq!(mobile_switcher_workspace_doc_range(&app, 2).start, 4); - let hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 4); - assert_eq!(hit, Some(MobileSwitcherTarget::Workspace(2))); - } - - #[test] - fn switcher_without_agents_keeps_spaces_first() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![crate::workspace::Workspace::test_new("shell-only")]; - app.active = Some(0); - app.selected = 0; - - // No attached terminals -> no agents -> no agents header, spaces lead. - assert_eq!(agent_panel_entries(&app).len(), 0); - assert_eq!(mobile_switcher_workspace_doc_range(&app, 0).start, 2); - } - - #[test] - fn mobile_agent_detail_includes_tab_context_when_available() { - let entry = agent_entry(Some("mobile-state"), Some("pi")); - - assert_eq!(mobile_agent_detail(&entry), " mobile-state · idle · pi"); - } - - #[test] - fn mobile_agent_detail_keeps_existing_compact_detail_without_tab_context() { - let entry = agent_entry(None, Some("pi")); - - assert_eq!(mobile_agent_detail(&entry), " idle · pi"); - } - - #[test] - fn mobile_tab_status_uses_compact_tab_label_and_position() { - let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs"); - let removed_tab = workspace.test_add_tab(None); - workspace.test_add_tab(None); - assert!(workspace.close_tab(removed_tab)); - workspace.active_tab = 1; - - assert_eq!(mobile_tab_status(&workspace), "tab 2 · 2/2"); - } - - #[test] - fn mobile_switcher_uses_compact_tab_label_for_auto_tab_labels() { - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs"); - let removed_tab = workspace.test_add_tab(None); - workspace.test_add_tab(None); - assert!(workspace.close_tab(removed_tab)); - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.active = Some(0); - app.selected = 0; - app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); - app.view.terminal_area = Rect::new(0, 2, 40, 18); - - let backend = ratatui::backend::TestBackend::new(40, 20); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| { - render_mobile_panel( - &app, - &TerminalRuntimeRegistry::new(), - frame, - Rect::new(0, 0, 40, 20), - ) - }) - .unwrap(); - - let row = (0..40) - .map(|x| terminal.backend().buffer()[(x, 10)].symbol()) - .collect::(); - - assert!(row.contains("tab 2"), "mobile tab row: {row:?}"); - assert!(!row.contains("tab 3"), "mobile tab row: {row:?}"); - } - - #[cfg(unix)] - #[tokio::test] - async fn mobile_header_uses_live_root_runtime_cwd_for_workspace_label() { - let unique = format!( - "herdr-mobile-header-runtime-cwd-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - let root = std::env::temp_dir().join(unique); - let stale_cwd = root.join("issue-264-nix-support"); - let live_cwd = root.join("herdr"); - std::fs::create_dir_all(stale_cwd.join(".git")).unwrap(); - std::fs::create_dir_all(live_cwd.join(".git")).unwrap(); - - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = crate::workspace::Workspace::test_new("stale-name"); - workspace.custom_name = None; - workspace.identity_cwd = stale_cwd.clone(); - let pane = workspace.tabs[0].root_pane; - - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; - app.active = Some(0); - app.selected = 0; - app.view.mobile_menu_hit_area = Rect::new(30, 0, 10, 2); - - let (events, _) = tokio::sync::mpsc::channel(4); - let runtime = crate::terminal::TerminalRuntime::spawn( - pane, - 24, - 80, - live_cwd.clone(), - 0, - crate::terminal_theme::TerminalTheme::default(), - None, - crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), - &crate::pane::PaneLaunchEnv::default(), - events, - std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(crate::render_signal::RenderSignal::new()), - ) - .unwrap(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - let mut runtime_registry = TerminalRuntimeRegistry::new(); - runtime_registry.insert(terminal_id, runtime); - let backend = ratatui::backend::TestBackend::new(40, 2); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| { - render_mobile_header(&app, &runtime_registry, frame, Rect::new(0, 0, 40, 2)) - }) - .unwrap(); - let row = (0..40) - .map(|x| terminal.backend().buffer()[(x, 0)].symbol()) - .collect::(); - - for (_, runtime) in runtime_registry.drain() { - runtime.shutdown(); - } - let _ = std::fs::remove_dir_all(root); - - assert!(row.contains("herdr"), "header row: {row:?}"); - assert!( - !row.contains("issue-264-nix-support"), - "header row: {row:?}" - ); - } -} diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs deleted file mode 100644 index cb2f235e..00000000 --- a/src/ui/navigator.rs +++ /dev/null @@ -1,643 +0,0 @@ -use ratatui::{ - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, Paragraph}, - Frame, -}; - -use super::{ - scrollbar::{render_scrollbar, should_show_scrollbar}, - status::{state_icon, state_label_color}, - text::{display_width_u16, middle_elide, truncate_end}, - widgets::{panel_contrast_fg, render_panel_shell}, -}; -use crate::app::state::{ - navigator_display_lines, AppState, NavigatorDisplayLine, NavigatorRow, NavigatorStateFilter, - NavigatorTarget, -}; -use crate::terminal::TerminalRuntimeRegistry; - -pub(super) fn render_navigator_overlay( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, -) { - let popup = app.navigator_popup_rect(); - let Some(inner) = render_panel_shell(frame, popup, app.palette.accent, app.palette.panel_bg) - else { - return; - }; - - let search = app.navigator_search_rect(); - let body = app.navigator_body_rect(); - let detail = app.navigator_detail_rect(); - let footer = app.navigator_footer_rect(); - render_search(app, frame, search); - - if body.height > 0 { - let rows = app.navigator_rows_from(terminal_runtimes); - let lines = navigator_display_lines(&rows); - render_separator(frame, Rect::new(inner.x, search.y + 1, inner.width, 1), app); - render_rows(app, &rows, &lines, frame, body); - render_navigator_scrollbar(app, lines.len(), frame, body); - } - render_detail(app, terminal_runtimes, frame, detail); - render_footer(app, frame, footer); -} - -fn render_search(app: &AppState, frame: &mut Frame, area: Rect) { - let p = &app.palette; - let focus_style = if app.navigator.search_focused { - Style::default().fg(p.accent).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.overlay0) - }; - let count = app - .workspaces - .iter() - .flat_map(|workspace| workspace.tabs.iter()) - .map(|tab| tab.panes.len()) - .sum::(); - let mut spans = vec![Span::styled(" / ", focus_style)]; - let query = app.navigator.query.trim(); - match app.navigator.state_filter { - Some(NavigatorStateFilter::Blocked) => push_state_chip( - &mut spans, - crate::detect::AgentState::Blocked, - true, - "blocked", - app, - ), - Some(NavigatorStateFilter::Working) => push_state_chip( - &mut spans, - crate::detect::AgentState::Working, - true, - "working", - app, - ), - Some(NavigatorStateFilter::Idle) => push_state_chip( - &mut spans, - crate::detect::AgentState::Idle, - true, - "idle", - app, - ), - Some(NavigatorStateFilter::Done) => push_state_chip( - &mut spans, - crate::detect::AgentState::Idle, - false, - "done", - app, - ), - None if query.is_empty() => spans.push(Span::styled( - "search panes", - Style::default().fg(p.overlay0), - )), - None => spans.push(Span::styled(query.to_string(), Style::default().fg(p.text))), - } - spans.push(Span::styled( - format!( - "{count:>width$} panes", - width = area.width.saturating_sub(16) as usize - ), - Style::default().fg(p.overlay0), - )); - frame.render_widget(Paragraph::new(Line::from(spans)), area); -} - -fn push_state_chip( - spans: &mut Vec>, - state: crate::detect::AgentState, - seen: bool, - label: &'static str, - app: &AppState, -) { - let (icon, icon_style) = state_icon(state, seen, app.status_indicators, &app.palette); - spans.push(Span::styled(icon, icon_style.add_modifier(Modifier::BOLD))); - spans.push(Span::raw(" ")); - spans.push(Span::styled( - label, - Style::default() - .fg(state_label_color(state, seen, &app.palette)) - .add_modifier(Modifier::BOLD), - )); -} - -fn render_separator(frame: &mut Frame, area: Rect, app: &AppState) { - if area.height == 0 || area.width == 0 { - return; - } - let line = "─".repeat(area.width as usize); - frame.render_widget( - Paragraph::new(line).style(Style::default().fg(app.palette.surface1)), - area, - ); -} - -fn render_rows( - app: &AppState, - rows: &[NavigatorRow], - lines: &[NavigatorDisplayLine], - frame: &mut Frame, - body: Rect, -) { - let start = app.navigator.scroll.min(lines.len()); - let end = lines.len().min(start.saturating_add(body.height as usize)); - for (visible_idx, line) in lines[start..end].iter().enumerate() { - let NavigatorDisplayLine::Row(idx) = *line else { - continue; - }; - let y = body.y + visible_idx as u16; - let rect = Rect::new(body.x, y, body.width, 1); - let selected = idx == app.navigator.selected; - render_row(app, frame, rect, rows, idx, selected); - } -} - -fn render_row( - app: &AppState, - frame: &mut Frame, - rect: Rect, - rows: &[NavigatorRow], - idx: usize, - selected: bool, -) { - let row = &rows[idx]; - let p = &app.palette; - frame.render_widget(Clear, rect); - let base_style = if selected { - Style::default().bg(p.accent).fg(panel_contrast_fg(p)) - } else { - Style::default().bg(p.panel_bg).fg(p.text) - }; - let dim_style = if selected { - base_style - } else { - Style::default().fg(p.overlay0).bg(p.panel_bg) - }; - let filter_active = - app.navigator.state_filter.is_some() || !app.navigator.query.trim().is_empty(); - let context_only = filter_active && !row.matched; - let text_style = if selected { - base_style.add_modifier(Modifier::BOLD) - } else if context_only { - let dimmed = Style::default().fg(p.overlay0).bg(p.panel_bg); - if row.is_workspace { - dimmed.add_modifier(Modifier::BOLD) - } else { - dimmed - } - } else if row.is_workspace { - Style::default() - .fg(p.accent) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD) - } else if row.is_current { - Style::default() - .fg(p.text) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0).bg(p.panel_bg) - }; - let (status_icon, status_style) = state_icon(row.status, row.seen, app.status_indicators, p); - let status_style = if selected { - base_style.add_modifier(Modifier::BOLD) - } else if context_only { - Style::default().fg(p.overlay0).bg(p.panel_bg) - } else { - status_style.bg(p.panel_bg) - }; - - let prefix = tree_prefix(rows, idx); - let current = if row.is_current { "◆" } else { " " }; - let gutter = format!(" {current} "); - let gutter_style = if selected { - base_style - } else if row.is_current { - Style::default().fg(p.accent).bg(p.panel_bg) - } else { - dim_style - }; - // Branch glyphs recede one shade below the workspace caret so the - // structure stays behind the labels. - let tree_style = if selected { - base_style - } else if row.is_workspace { - dim_style - } else { - Style::default().fg(p.surface1).bg(p.panel_bg) - }; - let meta_width = metadata_width(rect.width); - let left_budget = rect - .width - .saturating_sub(meta_width) - .saturating_sub(display_width_u16(&format!("{gutter}{prefix} "))) - .saturating_sub(3) as usize; - let title = truncate_end(&row.label, left_budget); - - let spans = vec![ - Span::styled(gutter, gutter_style), - Span::styled(prefix, tree_style), - Span::styled(" ", base_style), - Span::styled(status_icon, status_style), - Span::raw(" "), - Span::styled(title, text_style), - ]; - frame.render_widget(Paragraph::new(Line::from(spans)).style(base_style), rect); - - if meta_width > 0 { - let meta_rect = Rect::new( - rect.x + rect.width.saturating_sub(meta_width), - rect.y, - meta_width, - 1, - ); - let meta = truncate_end(&row.meta, meta_width.saturating_sub(2) as usize); - let meta_style = if selected { - base_style - } else if context_only || row.is_workspace || row.is_tab { - Style::default().fg(p.overlay0).bg(p.panel_bg) - } else { - Style::default() - .fg(state_label_color(row.status, row.seen, p)) - .bg(p.panel_bg) - }; - frame.render_widget( - Paragraph::new(format!(" {meta}")).style(meta_style), - meta_rect, - ); - } -} - -/// Tree prefix for a navigator row: expand caret for workspaces, connected -/// branch glyphs for children (`├──`, `└──` for the last sibling, with `│` -/// continuation lines under ancestors that have more siblings below). -fn tree_prefix(rows: &[NavigatorRow], idx: usize) -> String { - let row = &rows[idx]; - if row.is_workspace { - return if row.expanded { "▾" } else { "▸" }.to_string(); - } - if row.depth == 0 { - return " ".to_string(); - } - let mut prefix = String::new(); - for level in 1..row.depth { - prefix.push_str(if has_following_sibling_at_depth(rows, idx, level) { - "│ " - } else { - " " - }); - } - prefix.push_str(if has_following_sibling_at_depth(rows, idx, row.depth) { - "├──" - } else { - "└──" - }); - prefix -} - -/// Whether another row at `depth` follows `idx` before the subtree at that -/// depth ends (a row shallower than `depth` closes the subtree). -fn has_following_sibling_at_depth(rows: &[NavigatorRow], idx: usize, depth: u8) -> bool { - rows[idx + 1..] - .iter() - .take_while(|row| row.depth >= depth) - .any(|row| row.depth == depth) -} - -fn render_navigator_scrollbar(app: &AppState, line_count: usize, frame: &mut Frame, body: Rect) { - if body.width <= 1 || body.height == 0 { - return; - } - let viewport = body.height as usize; - if line_count <= viewport { - return; - } - let metrics = crate::pane::ScrollMetrics { - viewport_rows: viewport, - offset_from_bottom: line_count - .saturating_sub(viewport) - .saturating_sub(app.navigator.scroll), - max_offset_from_bottom: line_count.saturating_sub(viewport), - }; - if !should_show_scrollbar(metrics) { - return; - } - let track = Rect::new(body.x + body.width - 1, body.y, 1, body.height); - render_scrollbar( - frame, - metrics, - track, - app.palette.surface_dim, - app.palette.overlay0, - "▕", - ); -} - -fn metadata_width(width: u16) -> u16 { - if width >= 90 { - 28 - } else if width >= 68 { - 20 - } else if width >= 52 { - 14 - } else { - 0 - } -} - -fn render_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.height == 0 || area.width == 0 { - return; - } - render_separator(frame, area, app); - let detail = selected_detail(app, terminal_runtimes); - if detail.is_empty() { - return; - } - let text = middle_elide(&detail, area.width.saturating_sub(2) as usize); - frame.render_widget( - Paragraph::new(format!(" {text}")).style(Style::default().fg(app.palette.overlay0)), - area, - ); -} - -fn selected_detail(app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry) -> String { - let rows = app.navigator_rows_from(terminal_runtimes); - let Some(row) = rows.get(app.navigator.selected) else { - return String::new(); - }; - match row.target { - NavigatorTarget::Workspace { ws_idx } => workspace_detail(app, terminal_runtimes, ws_idx), - NavigatorTarget::Tab { ws_idx, tab_idx } => { - tab_detail(app, terminal_runtimes, ws_idx, tab_idx) - } - NavigatorTarget::Pane { - ws_idx, - tab_idx, - pane_id, - } => pane_detail(app, terminal_runtimes, ws_idx, tab_idx, pane_id), - } -} - -fn workspace_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, -) -> String { - let Some(ws) = app.workspaces.get(ws_idx) else { - return String::new(); - }; - let label = ws.display_name_from(&app.terminals, terminal_runtimes); - let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::(); - let mut parts = vec![label, format!("{pane_count} panes")]; - if !rowless_workspace_activity(app, terminal_runtimes, ws_idx).is_empty() { - parts.push(rowless_workspace_activity(app, terminal_runtimes, ws_idx)); - } - parts.join(" · ") -} - -fn tab_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, - tab_idx: usize, -) -> String { - let Some(ws) = app.workspaces.get(ws_idx) else { - return String::new(); - }; - let Some(tab) = ws.tabs.get(tab_idx) else { - return String::new(); - }; - let mut parts = vec![ - ws.display_name_from(&app.terminals, terminal_runtimes), - format!( - "tab: {}", - ws.tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()) - ), - format!("{} panes", tab.panes.len()), - ]; - let rows = app.navigator_rows_from(terminal_runtimes); - if let Some(meta) = rows - .into_iter() - .find(|row| matches!(row.target, NavigatorTarget::Tab { ws_idx: row_ws_idx, tab_idx: row_tab_idx } if row_ws_idx == ws_idx && row_tab_idx == tab_idx)) - .map(|row| row.meta) - .filter(|meta| !meta.is_empty()) - { - parts.push(meta); - } - parts.join(" · ") -} - -fn pane_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, - tab_idx: usize, - pane_id: crate::layout::PaneId, -) -> String { - let Some(ws) = app.workspaces.get(ws_idx) else { - return String::new(); - }; - let Some(tab) = ws.tabs.get(tab_idx) else { - return String::new(); - }; - let mut parts = vec![ws.display_name_from(&app.terminals, terminal_runtimes)]; - if ws.tabs.len() > 1 { - parts.push(format!( - "tab: {}", - ws.tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()) - )); - } - if let Some(pane_number) = ws.public_pane_number(pane_id) { - parts.push(format!("pane {pane_number}")); - } - if let Some(terminal_id) = tab.terminal_id(pane_id) { - if let Some(terminal) = app.terminals.get(terminal_id) { - let presentation = terminal.effective_presentation(); - if let Some(title) = presentation.title { - parts.push(title); - } - let display_agent = terminal.effective_display_agent(); - if let Some(agent) = display_agent.as_deref().or_else(|| { - terminal - .agent_name - .as_deref() - .or_else(|| terminal.effective_agent_label()) - }) { - parts.push(agent.to_string()); - let seen = tab - .panes - .get(&pane_id) - .map(|pane| pane.seen) - .unwrap_or(true); - let state = row_state(app, ws_idx, tab_idx, pane_id); - let status = presentation - .state_labels - .get(display_state(state, seen)) - .cloned() - .unwrap_or_else(|| display_state(state, seen).to_string()); - parts.push(status); - } else { - parts.push("shell".to_string()); - } - } - } - parts.join(" · ") -} - -fn rowless_workspace_activity( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, -) -> String { - app.navigator_rows_from(terminal_runtimes) - .into_iter() - .find(|row| matches!(row.target, NavigatorTarget::Workspace { ws_idx: row_ws_idx } if row_ws_idx == ws_idx)) - .map(|row| row.meta) - .unwrap_or_default() -} - -fn row_state( - app: &AppState, - ws_idx: usize, - tab_idx: usize, - pane_id: crate::layout::PaneId, -) -> crate::detect::AgentState { - app.workspaces - .get(ws_idx) - .and_then(|ws| ws.tabs.get(tab_idx)) - .and_then(|tab| tab.terminal_id(pane_id)) - .and_then(|terminal_id| app.terminals.get(terminal_id)) - .map(|terminal| terminal.state) - .unwrap_or(crate::detect::AgentState::Unknown) -} - -fn display_state(state: crate::detect::AgentState, seen: bool) -> &'static str { - match (state, seen) { - (crate::detect::AgentState::Blocked, _) => "blocked", - (crate::detect::AgentState::Working, _) => "working", - (crate::detect::AgentState::Idle, false) => "done", - (crate::detect::AgentState::Idle, true) => "idle", - (crate::detect::AgentState::Unknown, _) => "unknown", - } -} - -fn render_footer(app: &AppState, frame: &mut Frame, area: Rect) { - if area.height == 0 { - return; - } - let p = &app.palette; - let key = Style::default().fg(p.accent).add_modifier(Modifier::BOLD); - let dim = Style::default().fg(p.overlay0); - let line = if app.navigator.search_focused { - Line::from(vec![ - Span::styled(" enter", key), - Span::styled(" switch ", dim), - Span::styled("↑↓", key), - Span::styled(" move ", dim), - Span::styled("ctrl+u", key), - Span::styled(" clear ", dim), - Span::styled("esc", key), - Span::styled(" back", dim), - ]) - } else { - Line::from(vec![ - Span::styled(" enter", key), - Span::styled(" switch ", dim), - Span::styled("/", key), - Span::styled(" search ", dim), - Span::styled("b/w/i/d/a", key), - Span::styled(" states ", dim), - Span::styled("j/k/↑↓", key), - Span::styled(" move ", dim), - Span::styled("esc", key), - Span::styled(" close", dim), - ]) - }; - frame.render_widget(Paragraph::new(line), area); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::detect::AgentState; - - fn row(depth: u8, is_workspace: bool) -> NavigatorRow { - NavigatorRow { - target: NavigatorTarget::Workspace { ws_idx: 0 }, - depth, - label: String::new(), - meta: String::new(), - status: AgentState::Idle, - seen: true, - is_current: false, - is_workspace, - is_tab: false, - expanded: true, - search_text: String::new(), - matched: true, - } - } - - fn multi_tab_rows() -> Vec { - vec![ - row(0, true), // workspace - row(1, false), // tab a - row(2, false), // pane - row(2, false), // pane (last in tab a) - row(1, false), // tab b (last tab) - row(2, false), // pane (last in tab b) - row(0, true), // workspace - row(1, false), // pane (single child) - ] - } - - #[test] - fn workspace_rows_use_expand_caret() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 0), "▾"); - let mut collapsed = rows.clone(); - collapsed[0].expanded = false; - assert_eq!(tree_prefix(&collapsed, 0), "▸"); - } - - #[test] - fn middle_children_get_branch_glyph() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 1), "├──"); - assert_eq!(tree_prefix(&rows, 2), "│ ├──"); - } - - #[test] - fn last_children_get_terminator_glyph() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 3), "│ └──"); - assert_eq!(tree_prefix(&rows, 4), "└──"); - assert_eq!(tree_prefix(&rows, 7), "└──"); - } - - #[test] - fn spine_stops_after_last_ancestor_sibling() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 5), " └──"); - } - - #[test] - fn next_workspace_does_not_extend_previous_subtree() { - // The pane at idx 5 is last in its workspace even though another - // workspace with children follows. - let rows = multi_tab_rows(); - assert!(!has_following_sibling_at_depth(&rows, 5, 1)); - assert!(!has_following_sibling_at_depth(&rows, 5, 2)); - } -} diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs index 4ac6d9b8..94c9b2a0 100644 --- a/src/ui/onboarding.rs +++ b/src/ui/onboarding.rs @@ -1,16 +1,4 @@ -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::Paragraph, - Frame, -}; - -use super::widgets::{ - action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button, - render_modal_shell, -}; -use crate::app::AppState; +use ratatui::layout::Rect; pub(crate) const ONBOARDING_TITLE: &str = " herdr"; pub(crate) const ONBOARDING_SUBTITLE: &str = " terminal workspace manager for coding agents"; @@ -26,97 +14,6 @@ pub(crate) const ONBOARDING_HELP_SUFFIX: &str = " shows keybinds and settings"; pub(crate) const ONBOARDING_NEXT: &str = " next: install optional agent integrations for more reliable state"; -pub(super) fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - super::dim_background(frame, area); - render_onboarding_welcome(app, frame, area); -} - pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect { - Rect::new( - area.x, - area.y, - action_button_width(Some("↵"), "continue"), - 1, - ) -} - -fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(inner) = render_modal_shell(frame, area, 64, 16, &app.palette) else { - return; - }; - if inner.height < 11 { - return; - } - - let stack = modal_stack_areas(inner, 2, 0, 1, 1); - let header_rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header); - let content_rows = Layout::vertical([ - Constraint::Length(3), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<4>(stack.content); - - frame.render_widget( - Paragraph::new(ONBOARDING_TITLE).style( - Style::default() - .fg(app.palette.text) - .add_modifier(Modifier::BOLD), - ), - header_rows[0], - ); - frame.render_widget( - Paragraph::new(ONBOARDING_SUBTITLE).style(Style::default().fg(app.palette.overlay0)), - header_rows[1], - ); - - frame.render_widget( - Paragraph::new(ONBOARDING_DESCRIPTION.join("\n")) - .style(Style::default().fg(app.palette.overlay1)), - content_rows[0], - ); - - let key_line = Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - ONBOARDING_PREFIX_LABEL, - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - ONBOARDING_PREFIX_SUFFIX, - Style::default().fg(app.palette.overlay1), - ), - Span::styled( - ONBOARDING_HELP_LABEL, - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - ONBOARDING_HELP_SUFFIX, - Style::default().fg(app.palette.overlay1), - ), - ]); - frame.render_widget(Paragraph::new(key_line), content_rows[2]); - - frame.render_widget( - Paragraph::new(ONBOARDING_NEXT).style(Style::default().fg(app.palette.overlay1)), - content_rows[3], - ); - - let continue_rect = onboarding_welcome_continue_rect(stack.actions.unwrap_or_default()); - render_action_button( - frame, - continue_rect, - Some("↵"), - "continue", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); + super::widgets::continue_button_rect(area) } diff --git a/src/ui/panes.rs b/src/ui/panes.rs index 3d1b625c..cd5ea34c 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -2,8 +2,7 @@ use ratatui::{ buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders}, Frame, }; @@ -13,7 +12,7 @@ use super::text::display_width; use super::text::truncate_end; use super::widgets::panel_contrast_fg; use crate::app::state::Palette; -use crate::app::{AppState, Mode}; +use crate::app::AppState; use crate::layout::PaneInfo; use crate::popup_size::resolve_popup_geometry; use crate::terminal::{TerminalRuntime, TerminalRuntimeRegistry}; @@ -353,60 +352,13 @@ pub(super) fn render_panes( return; }; - let multi_pane = ws.layout.pane_count() > 1; - let terminal_active = app.mode == Mode::Terminal; - for info in pane_infos { if let Some(rt) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) { let show_cursor = info.is_focused - && terminal_active && !pane_is_scrolled_back(rt) && app.pane_exposes_host_cursor(ws_idx, info.id); rt.render(frame, info.inner_rect, show_cursor); render_pane_scrollbar(app, frame, info, rt); - - let should_dim = !info.is_focused && multi_pane && !terminal_active; - if should_dim { - let inner = info.inner_rect; - let buf = frame.buffer_mut(); - for y in inner.y..inner.y + inner.height { - for x in inner.x..inner.x + inner.width { - let cell = &mut buf[(x, y)]; - cell.set_style(cell.style().add_modifier(Modifier::DIM)); - } - } - } - - let (copy_search_top, copy_search_bottom, copy_search_matches) = - validated_copy_mode_search_matches(app, info, rt); - render_copy_mode_search_highlights( - app, - frame, - info, - copy_search_top, - copy_search_bottom, - ©_search_matches, - false, - ); - render_selection_highlight( - app.selection.as_ref(), - frame.buffer_mut(), - &info.id, - info.inner_rect, - rt.scroll_metrics(), - &app.palette, - app.host_terminal_theme, - ); - render_copy_mode_search_highlights( - app, - frame, - info, - copy_search_top, - copy_search_bottom, - ©_search_matches, - true, - ); - render_copy_mode_cursor(app, frame, info); } } @@ -444,36 +396,6 @@ pub(super) fn resize_popup_pane( } } -pub(super) fn render_popup_pane( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - let Some(popup) = app.popup_pane.as_ref() else { - return; - }; - let Some((outer, inner)) = popup_pane_rects(app, area) else { - return; - }; - let Some(rt) = terminal_runtimes.get(&popup.terminal_id) else { - return; - }; - let title = app - .terminals - .get(&popup.terminal_id) - .and_then(|terminal| terminal.manual_label.as_deref()) - .unwrap_or("popup"); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.palette.accent)) - .title(pane_border_title(title, outer.width, true).unwrap_or_default()) - .style(Style::default().bg(app.palette.panel_bg)); - frame.render_widget(Clear, outer); - frame.render_widget(block, outer); - rt.render(frame, inner, !pane_is_scrolled_back(rt)); -} - #[derive(Clone, Copy, Default)] struct LineCell { up: bool, @@ -725,121 +647,6 @@ fn line_cell_symbol(line: LineCell) -> &'static str { } } -fn render_copy_mode_cursor(app: &AppState, frame: &mut Frame, info: &PaneInfo) { - if app.mode != Mode::Copy { - return; - } - let Some(copy_mode) = app.copy_mode.as_ref() else { - return; - }; - if copy_mode.pane_id != info.id - || copy_mode.cursor_row >= info.inner_rect.height - || copy_mode.cursor_col >= info.inner_rect.width - { - return; - } - - let x = info.inner_rect.x + copy_mode.cursor_col; - let y = info.inner_rect.y + copy_mode.cursor_row; - let cell = &mut frame.buffer_mut()[(x, y)]; - cell.set_style( - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); -} - -fn validated_copy_mode_search_matches( - app: &AppState, - info: &PaneInfo, - rt: &crate::terminal::TerminalRuntime, -) -> (u32, u32, Vec<(usize, crate::pane::TerminalTextMatch)>) { - let Some(copy_mode) = app.copy_mode.as_ref() else { - return (0, 0, Vec::new()); - }; - if copy_mode.pane_id != info.id { - return (0, 0, Vec::new()); - } - let Some(metrics) = rt.scroll_metrics() else { - return (0, 0, Vec::new()); - }; - let top = metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom) - .min(u32::MAX as usize) as u32; - let bottom = top.saturating_add(u32::from(info.inner_rect.height.saturating_sub(1))); - let first_visible = copy_mode - .search - .matches - .partition_point(|text_match| text_match.end.row < top); - let visible = ©_mode.search.matches[first_visible..]; - let visible_len = visible.partition_point(|text_match| text_match.start.row <= bottom); - let candidates = visible[..visible_len].to_vec(); - let validity = rt.text_matches_are_current(&candidates); - - let matches = candidates - .into_iter() - .zip(validity) - .enumerate() - .filter_map(|(offset, (text_match, is_current))| { - is_current.then_some((first_visible + offset, text_match)) - }) - .collect(); - (top, bottom, matches) -} - -fn render_copy_mode_search_highlights( - app: &AppState, - frame: &mut Frame, - info: &PaneInfo, - top: u32, - bottom: u32, - matches: &[(usize, crate::pane::TerminalTextMatch)], - current_only: bool, -) { - let Some(copy_mode) = app.copy_mode.as_ref() else { - return; - }; - let current = copy_mode.search.current; - let style = if current_only { - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface1) - }; - - for &(index, text_match) in matches { - if (current == Some(index)) != current_only { - 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 { - info.inner_rect.width.saturating_sub(1) - }; - for col in start_col..=end_col.min(info.inner_rect.width.saturating_sub(1)) { - let x = info.inner_rect.x.saturating_add(col); - let y = info.inner_rect.y.saturating_add(viewport_row); - frame.buffer_mut()[(x, y)].set_style(style); - } - } - } -} - pub(crate) fn render_selection_highlight( selection: Option<&crate::selection::Selection

>, buffer: &mut Buffer, @@ -959,47 +766,6 @@ fn color_to_rgb(color: Color) -> Option { } } -pub(super) fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) { - let p = &app.palette; - let lines = vec![ - Line::from(""), - Line::from(""), - Line::from(Span::styled( - " No workspaces yet", - Style::default().fg(p.overlay0), - )), - Line::from(""), - Line::from(Span::styled( - " A workspace is one project context.", - Style::default().fg(p.overlay1), - )), - Line::from(Span::styled( - " Its root pane (top-left) sets the default repo or folder name.", - Style::default().fg(p.overlay1), - )), - Line::from(""), - Line::from(vec![ - Span::styled(" Press ", Style::default().fg(p.overlay0)), - Span::styled( - app.keybinds - .new_workspace - .label() - .unwrap_or_else(|| "unset".to_string()), - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - Span::styled(" to create one", Style::default().fg(p.overlay0)), - ]), - ]; - frame.render_widget( - Paragraph::new(lines).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(p.surface_dim)), - ), - area, - ); -} - #[cfg(test)] mod tests { use super::*; @@ -1009,14 +775,13 @@ mod tests { use crate::terminal::TerminalState; use crate::workspace::Workspace; - fn render_view_pane_borders(app: &AppState, ws: &Workspace, frame: &mut Frame) { - render_pane_borders( - app, - ws, - &app.view.pane_infos, - &app.view.split_borders, - frame, - ); + fn render_view_pane_borders( + app: &AppState, + ws: &Workspace, + split_borders: &[crate::layout::SplitBorder], + frame: &mut Frame, + ) { + render_pane_borders(app, ws, &app.view.pane_infos, split_borders, frame); } #[test] @@ -1052,7 +817,6 @@ mod tests { #[test] fn pane_border_renderer_places_adjacent_cjk_by_display_width() { let mut app = AppState::test_new(); - app.mode = Mode::Terminal; app.view.terminal_area = Rect::new(0, 0, 12, 3); let ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; @@ -1073,7 +837,7 @@ mod tests { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(12, 3)).unwrap(); terminal - .draw(|frame| render_view_pane_borders(&app, &ws, frame)) + .draw(|frame| render_view_pane_borders(&app, &ws, &[], frame)) .unwrap(); let buffer = terminal.backend().buffer(); @@ -1208,7 +972,6 @@ mod tests { #[test] fn global_pane_border_renderer_composes_junctions_and_focus_style() { let mut app = AppState::test_new(); - app.mode = Mode::Terminal; app.view.terminal_area = Rect::new(0, 0, 4, 4); app.view.pane_infos = vec![ PaneInfo { @@ -1244,7 +1007,7 @@ mod tests { is_focused: false, }, ]; - app.view.split_borders = vec![ + let split_borders = vec![ crate::layout::SplitBorder { pos: 2, direction: ratatui::layout::Direction::Horizontal, @@ -1265,7 +1028,7 @@ mod tests { ratatui::Terminal::new(ratatui::backend::TestBackend::new(4, 4)).unwrap(); terminal - .draw(|frame| render_view_pane_borders(&app, &ws, frame)) + .draw(|frame| render_view_pane_borders(&app, &ws, &split_borders, frame)) .unwrap(); let buffer = terminal.backend().buffer(); @@ -1278,7 +1041,6 @@ mod tests { #[test] fn gapped_pane_focus_does_not_color_neighbor_border() { let mut app = AppState::test_new(); - app.mode = Mode::Terminal; app.pane_gaps = true; app.view.terminal_area = Rect::new(0, 0, 4, 3); app.view.pane_infos = vec![ @@ -1304,7 +1066,7 @@ mod tests { ratatui::Terminal::new(ratatui::backend::TestBackend::new(4, 3)).unwrap(); terminal - .draw(|frame| render_view_pane_borders(&app, &ws, frame)) + .draw(|frame| render_view_pane_borders(&app, &ws, &[], frame)) .unwrap(); let buffer = terminal.backend().buffer(); @@ -1532,7 +1294,11 @@ mod tests { ..Default::default() }; let expected_style = automatic_selection_style(&palette, host_theme); - let selection = Some(Selection::range(PaneId::from_raw(1), 0, 0, 2, None)); + let selection = Some(Selection::absolute_range( + PaneId::from_raw(1), + (0, 0), + (0, 2), + )); let backend = ratatui::backend::TestBackend::new(4, 1); let mut terminal = ratatui::Terminal::new(backend).unwrap(); diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs index a1db7303..5f6dc078 100644 --- a/src/ui/release_notes.rs +++ b/src/ui/release_notes.rs @@ -1,250 +1,15 @@ use ratatui::{ - layout::{Constraint, Layout, Rect}, + layout::Rect, style::{Modifier, Style}, text::{Line, Span}, widgets::{Paragraph, Wrap}, - Frame, }; -use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar}; -use super::widgets::{ - action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button, - render_modal_header, render_modal_shell, -}; -use crate::app::{ - state::{Palette, ProductAnnouncementState, ReleaseNotesState}, - AppState, -}; +use crate::app::state::{Palette, ProductAnnouncementState, ReleaseNotesState}; pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24); pub(crate) const PRODUCT_ANNOUNCEMENT_MODAL_SIZE: (u16, u16) = (88, 24); -pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(notes) = &app.release_notes else { - return; - }; - - super::dim_background(frame, area); - - let Some(inner) = render_modal_shell( - frame, - area, - RELEASE_NOTES_MODAL_SIZE.0, - RELEASE_NOTES_MODAL_SIZE.1, - &app.palette, - ) else { - return; - }; - if inner.height < 8 || inner.width < 20 { - return; - } - - let stack = modal_stack_areas(inner, 2, 1, 0, 1); - let header_rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header); - - let header_title_area = Rect::new( - header_rows[0].x + 1, - header_rows[0].y, - header_rows[0].width.saturating_sub(2), - header_rows[0].height, - ); - let header_subtitle_area = Rect::new( - header_rows[1].x + 1, - header_rows[1].y, - header_rows[1].width.saturating_sub(2), - header_rows[1].height, - ); - - render_modal_header( - frame, - header_title_area, - &format!("v{}", notes.version), - &app.palette, - ); - let subtitle = if notes.preview { - "update ready" - } else { - "what's new in this release" - }; - frame.render_widget( - Paragraph::new(subtitle).style(Style::default().fg(app.palette.overlay1)), - header_subtitle_area, - ); - render_action_button( - frame, - release_notes_close_button_rect(header_rows[0]), - Some("esc"), - "close", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - - let notes_body = stack.content; - let display_lines = - release_notes_display_lines(notes, &app.update_install_command, &app.palette); - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: app.release_notes_max_scroll().saturating_sub(notes.scroll) as usize, - max_offset_from_bottom: app.release_notes_max_scroll() as usize, - viewport_rows: notes_body.height.max(1) as usize, - }; - let track = release_notes_scrollbar_rect(notes_body, metrics); - let notes_text_area = track - .map(|_| { - Rect::new( - notes_body.x, - notes_body.y, - notes_body.width.saturating_sub(1), - notes_body.height, - ) - }) - .unwrap_or(notes_body); - - let body = Paragraph::new( - display_lines - .into_iter() - .map(|(_, line)| line) - .collect::>(), - ) - .wrap(Wrap { trim: false }) - .scroll((notes.scroll, 0)); - frame.render_widget(body, notes_text_area); - if let Some(track) = track { - render_scrollbar( - frame, - metrics, - track, - app.palette.overlay0, - app.palette.overlay1, - "▐", - ); - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("close", Style::default().fg(app.palette.overlay0)), - Span::styled(" esc / enter ", Style::default().fg(app.palette.text)), - ])), - stack.footer.unwrap_or_default(), - ); -} - -pub(super) fn render_product_announcement_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(announcement) = &app.product_announcement else { - return; - }; - - super::dim_background(frame, area); - - let Some(inner) = render_modal_shell( - frame, - area, - PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, - PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1, - &app.palette, - ) else { - return; - }; - if inner.height < 8 || inner.width < 20 { - return; - } - - let stack = modal_stack_areas(inner, 2, 1, 0, 1); - let header_rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header); - - let header_title_area = Rect::new( - header_rows[0].x + 1, - header_rows[0].y, - header_rows[0].width.saturating_sub(2), - header_rows[0].height, - ); - let header_subtitle_area = Rect::new( - header_rows[1].x + 1, - header_rows[1].y, - header_rows[1].width.saturating_sub(2), - header_rows[1].height, - ); - - render_modal_header(frame, header_title_area, &announcement.title, &app.palette); - let subtitle = if announcement.preview { - "product announcement preview" - } else { - "product announcement" - }; - frame.render_widget( - Paragraph::new(format!("{subtitle} · v{}", announcement.version)) - .style(Style::default().fg(app.palette.overlay1)), - header_subtitle_area, - ); - render_action_button( - frame, - release_notes_close_button_rect(header_rows[0]), - Some("esc"), - "close", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - - let body_rect = stack.content; - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: app - .product_announcement_max_scroll() - .saturating_sub(announcement.scroll) as usize, - max_offset_from_bottom: app.product_announcement_max_scroll() as usize, - viewport_rows: body_rect.height.max(1) as usize, - }; - let track = release_notes_scrollbar_rect(body_rect, metrics); - let text_area = track - .map(|_| { - Rect::new( - body_rect.x, - body_rect.y, - body_rect.width.saturating_sub(1), - body_rect.height, - ) - }) - .unwrap_or(body_rect); - - let body = Paragraph::new( - product_announcement_display_lines(announcement, &app.palette) - .into_iter() - .map(|(_, line)| line) - .collect::>(), - ) - .wrap(Wrap { trim: false }) - .scroll((announcement.scroll, 0)); - frame.render_widget(body, text_area); - if let Some(track) = track { - render_scrollbar( - frame, - metrics, - track, - app.palette.overlay0, - app.palette.overlay1, - "▐", - ); - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("close", Style::default().fg(app.palette.overlay0)), - Span::styled(" esc / enter ", Style::default().fg(app.palette.text)), - ])), - stack.footer.unwrap_or_default(), - ); -} - fn release_notes_inline_spans<'a>( text: &str, base_style: Style, @@ -487,8 +252,7 @@ pub(crate) fn release_notes_wrapped_line_count(lines: &[(usize, Line<'_>)], widt } pub(crate) fn release_notes_close_button_rect(area: Rect) -> Rect { - let width = action_button_width(Some("esc"), "close"); - Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1) + super::widgets::close_button_rect(area) } #[cfg(test)] diff --git a/src/ui/settings.rs b/src/ui/settings.rs deleted file mode 100644 index 3cea6da3..00000000 --- a/src/ui/settings.rs +++ /dev/null @@ -1,424 +0,0 @@ -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{List, ListItem, ListState, Paragraph, Tabs}, - Frame, -}; - -use super::widgets::{ - action_button_row_rects, centered_popup_rect, modal_stack_areas, panel_contrast_fg, - render_action_button, render_modal_choice_list, render_panel_shell, ActionButtonSpec, -}; -use crate::{ - app::{state::Palette, AppState}, - config::{StatusIndicatorStyle, ToastDelivery}, -}; - -pub(crate) const SETTINGS_POPUP_WIDTH: u16 = 76; -pub(crate) const SETTINGS_POPUP_BASE_HEIGHT: u16 = 22; - -pub(crate) fn settings_popup_height(app: &AppState) -> u16 { - if app.settings.section != crate::app::state::SettingsSection::Integrations { - return SETTINGS_POPUP_BASE_HEIGHT; - } - let list_rows = app.integration_recommendations.len().max(1) as u16; - let footer_rows = integrations_footer_height(app, SETTINGS_POPUP_WIDTH - 2); - // borders 2 + header 3 + stack gaps 2 + modal footer 2 - // + section title 1 + description 2 + spacers 2 - (14 + list_rows + footer_rows).max(SETTINGS_POPUP_BASE_HEIGHT) -} - -pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - use crate::app::state::SettingsSection; - - let p = &app.palette; - let Some(popup) = centered_popup_rect(area, SETTINGS_POPUP_WIDTH, settings_popup_height(app)) - else { - return; - }; - - super::dim_background(frame, area); - - let Some(inner) = render_panel_shell(frame, popup, p.accent, p.panel_bg) else { - return; - }; - if inner.height < 4 || inner.width < 10 { - return; - } - - let stack = modal_stack_areas(inner, 3, 2, 0, 1); - let header_rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas::<3>(stack.header); - - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " settings", - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - )])), - header_rows[0], - ); - - let tab_labels = SettingsSection::ALL.iter().map(|section| { - if app.settings_section_has_badge(*section) { - Line::from(vec![ - Span::styled( - "● ", - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - Span::raw(section.label()), - ]) - } else { - Line::from(section.label()) - } - }); - let tabs = Tabs::new(tab_labels) - .select( - SettingsSection::ALL - .iter() - .position(|section| *section == app.settings.section) - .unwrap_or(0), - ) - .style(Style::default().fg(p.overlay1)) - .highlight_style( - Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.accent) - .add_modifier(Modifier::BOLD), - ) - .divider(" ") - .padding(" ", " "); - frame.render_widget(tabs, header_rows[1]); - - let sep = "─".repeat(inner.width as usize); - frame.render_widget( - Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))), - header_rows[2], - ); - - let content_area = stack.content; - - match app.settings.section { - SettingsSection::Theme => { - render_settings_theme(app, frame, content_area); - } - SettingsSection::Indicators => { - render_modal_choice_list( - frame, - content_area, - "agent status indicators", - "choose color dots or distinct symbols for each state", - &[ - ("color dots ● ● ● ○ ·", StatusIndicatorStyle::Dots), - ("distinct symbols × ◐ ✓ ○ ·", StatusIndicatorStyle::Symbols), - ], - app.status_indicators, - app.settings.list.selected, - p, - 1, - ); - } - SettingsSection::Sound => { - render_settings_toggle( - frame, - content_area, - p, - "sound alerts", - "play sounds when agents change state in background", - app.sound_enabled(), - app.settings.list.selected, - ); - } - SettingsSection::Toast => { - render_modal_choice_list( - frame, - content_area, - "notification popups", - "choose where background popup notifications should appear", - &[ - ("off", ToastDelivery::Off), - ("inside herdr", ToastDelivery::Herdr), - ("via terminal", ToastDelivery::Terminal), - ("via system", ToastDelivery::System), - ], - app.toast_delivery(), - app.settings.list.selected, - p, - 2, - ); - } - SettingsSection::PaneLabels => { - render_settings_toggle( - frame, - content_area, - p, - "agent border labels", - "show detected agent names in split pane borders", - app.agent_border_labels_enabled(), - app.settings.list.selected, - ); - } - SettingsSection::Integrations => { - render_settings_integrations(app, frame, content_area); - } - } - - if let Some(footer_area) = stack.footer { - let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]) - .areas::<2>(footer_area); - let primary_label = settings_primary_button_label(app.settings.section); - let show_primary = settings_show_primary_action(app); - let (apply_rect, close_rect) = - settings_button_rects(inner, app.settings.section, show_primary); - if let Some(apply_rect) = apply_rect { - render_action_button( - frame, - apply_rect, - Some("↵"), - primary_label, - Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.accent) - .add_modifier(Modifier::BOLD), - ); - } - render_action_button( - frame, - close_rect, - Some("esc"), - "close", - Style::default() - .fg(p.text) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ); - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" ↑↓", Style::default().fg(p.overlay0)), - Span::styled(" select ", Style::default().fg(p.overlay1)), - Span::styled("tab", Style::default().fg(p.overlay0)), - Span::styled(" section", Style::default().fg(p.overlay1)), - ])), - footer_rows[0], - ); - } -} - -pub(crate) fn settings_primary_button_label( - section: crate::app::state::SettingsSection, -) -> &'static str { - match section { - crate::app::state::SettingsSection::Integrations => "install", - _ => "apply", - } -} - -pub(crate) fn settings_show_primary_action(app: &AppState) -> bool { - match app.settings.section { - crate::app::state::SettingsSection::Integrations => app - .integration_recommendations - .iter() - .any(crate::integration::IntegrationRecommendation::needs_install), - _ => true, - } -} - -pub(crate) fn settings_button_rects( - inner: Rect, - section: crate::app::state::SettingsSection, - show_primary: bool, -) -> (Option, Rect) { - if !show_primary { - let rects = action_button_row_rects( - inner, - &[ActionButtonSpec { - hint: Some("esc"), - label: "close", - }], - 2, - inner.height.saturating_sub(1), - ); - return (None, rects[0]); - } - - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: settings_primary_button_label(section), - }, - ActionButtonSpec { - hint: Some("esc"), - label: "close", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (Some(rects[0]), rects[1]) -} - -fn integrations_footer_paragraph(app: &AppState) -> Paragraph<'static> { - let p = &app.palette; - let mut footer_lines = Vec::new(); - if !app.integration_install_messages.is_empty() { - for message in &app.integration_install_messages { - footer_lines.push(Line::from(Span::styled( - format!(" {message}"), - Style::default().fg(p.overlay1), - ))); - } - } else { - let found_any = app.integration_recommendations.iter().any(|item| { - item.available || item.state != crate::integration::IntegrationStatusKind::NotInstalled - }); - let hint = if app - .integration_recommendations - .iter() - .any(crate::integration::IntegrationRecommendation::needs_install) - { - " press install to add available or outdated integrations" - } else if found_any { - " all detected integrations are installed" - } else { - " no supported agent CLIs found on PATH" - }; - footer_lines.push(Line::from(Span::styled( - hint.to_string(), - Style::default().fg(p.overlay1), - ))); - } - Paragraph::new(footer_lines).wrap(ratatui::widgets::Wrap { trim: false }) -} - -fn integrations_footer_height(app: &AppState, width: u16) -> u16 { - (integrations_footer_paragraph(app).line_count(width) as u16).min(6) -} - -fn render_settings_integrations(app: &AppState, frame: &mut Frame, area: Rect) { - let p = &app.palette; - - let footer = integrations_footer_paragraph(app); - let footer_height = integrations_footer_height(app, area.width); - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(2), - Constraint::Length(1), - Constraint::Min(0), - Constraint::Length(1), - Constraint::Length(footer_height), - ]) - .areas::<6>(area); - - frame.render_widget( - Paragraph::new("agent integrations") - .style(Style::default().fg(p.text).add_modifier(Modifier::BOLD)), - rows[0], - ); - frame.render_widget( - Paragraph::new( - "let agents report state directly instead of relying only on process detection", - ) - .style(Style::default().fg(p.overlay1)) - .wrap(ratatui::widgets::Wrap { trim: false }), - rows[1], - ); - - let mut lines = Vec::new(); - for item in &app.integration_recommendations { - let marker = match item.state { - crate::integration::IntegrationStatusKind::Current => "✓", - crate::integration::IntegrationStatusKind::Outdated => "↻", - crate::integration::IntegrationStatusKind::NotInstalled if item.available => "+", - crate::integration::IntegrationStatusKind::NotInstalled => "–", - }; - let marker_style = match item.state { - crate::integration::IntegrationStatusKind::Current => Style::default().fg(p.green), - crate::integration::IntegrationStatusKind::Outdated => Style::default().fg(p.yellow), - crate::integration::IntegrationStatusKind::NotInstalled if item.available => { - Style::default().fg(p.accent) - } - crate::integration::IntegrationStatusKind::NotInstalled => { - Style::default().fg(p.overlay0) - } - }; - lines.push(Line::from(vec![ - Span::styled(format!(" {marker} "), marker_style), - Span::styled( - format!("{:<9}", item.label), - Style::default().fg(p.subtext0), - ), - Span::styled(item.status_label(), Style::default().fg(p.overlay1)), - ])); - } - - if lines.is_empty() { - lines.push(Line::from(Span::styled( - " no integration targets available", - Style::default().fg(p.overlay1), - ))); - } - - frame.render_widget(Paragraph::new(lines), rows[3]); - frame.render_widget(footer, rows[5]); -} - -fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) { - use crate::app::state::THEME_NAMES; - - let p = &app.palette; - let items: Vec = THEME_NAMES - .iter() - .map(|name| { - let is_current = name.to_lowercase().replace([' ', '_'], "-") - == app.theme_name.to_lowercase().replace([' ', '_'], "-"); - let marker = if is_current { " ✓" } else { "" }; - ListItem::new(Line::from(vec![ - Span::styled(*name, Style::default().fg(p.subtext0)), - Span::styled(marker, Style::default().fg(p.green)), - ])) - }) - .collect(); - - let list = List::new(items) - .highlight_style( - Style::default() - .bg(p.surface0) - .fg(p.text) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(" ▸ ") - .style(Style::default().fg(p.subtext0)); - - let mut state = ListState::default().with_selected(Some(app.settings.list.selected)); - frame.render_stateful_widget(list, area, &mut state); -} - -fn render_settings_toggle( - frame: &mut Frame, - area: Rect, - p: &Palette, - title: &str, - description: &str, - current_value: bool, - selected_idx: usize, -) { - render_modal_choice_list( - frame, - area, - title, - description, - &[("on", true), ("off", false)], - current_value, - selected_idx, - p, - 1, - ); -} diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 6d2e6147..3da1e9f9 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -1,74 +1,68 @@ mod tokens; use ratatui::{ - layout::{Alignment, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::Paragraph, - Frame, + layout::Rect, + style::{Modifier, Style}, + text::Span, }; pub(crate) use self::tokens::{ agent_rows as sidebar_agent_rows, space_rows as sidebar_space_rows, AgentTokenContext, ResolvedToken, ResolvedTokenKind, SpaceTokenContext, }; -use super::scrollbar::{render_scrollbar, should_show_scrollbar}; -use super::status::{state_icon, state_label, state_label_color}; -use super::text::{display_width, display_width_u16, truncate_end}; -use crate::app::state::{AgentPanelSort, Palette}; -use crate::app::{AppState, Mode}; +use super::text::{display_width, truncate_end}; +use crate::app::state::Palette; +use crate::app::AppState; use crate::detect::AgentState; use crate::terminal::TerminalRuntimeRegistry; -const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2; -const AGENT_PANEL_HEADER_ROWS: u16 = 3; - pub(crate) struct AgentPanelEntry { pub ws_idx: usize, pub tab_idx: usize, pub pane_id: crate::layout::PaneId, - pub primary_label: String, - pub primary_tab_label: Option, - pub pane_label: Option, - pub terminal_title: Option, - pub terminal_title_stripped: Option, - pub agent_label: Option, pub agent_kind_label: Option, - pub agent: Option, pub state: AgentState, pub seen: bool, pub last_agent_state_change_seq: Option, - pub state_labels: std::collections::HashMap, pub tokens: std::collections::HashMap, } -fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) { - if total_h == 0 { +fn sidebar_section_heights(total_height: u16, split_ratio: f32) -> (u16, u16) { + if total_height == 0 { return (0, 0); } - - if total_h < 6 { - let ws_h = total_h.div_ceil(2); - return (ws_h, total_h.saturating_sub(ws_h)); + if total_height < 6 { + let workspace_height = total_height.div_ceil(2); + return ( + workspace_height, + total_height.saturating_sub(workspace_height), + ); } - let ratio = split_ratio.clamp(0.1, 0.9); - let ws_h = ((total_h as f32) * ratio).round() as u16; - let ws_h = ws_h.clamp(3, total_h.saturating_sub(3)); - let detail_h = total_h.saturating_sub(ws_h); - (ws_h, detail_h) + let workspace_height = ((total_height as f32) * split_ratio.clamp(0.1, 0.9)).round() as u16; + let workspace_height = workspace_height.clamp(3, total_height.saturating_sub(3)); + ( + workspace_height, + total_height.saturating_sub(workspace_height), + ) } pub(crate) fn expanded_sidebar_sections(area: Rect, split_ratio: f32) -> (Rect, Rect) { let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); - if content.width == 0 || content.height == 0 { + if content.is_empty() { return (Rect::default(), Rect::default()); } - let (ws_h, detail_h) = sidebar_section_heights(content.height, split_ratio); - let ws_area = Rect::new(content.x, content.y, content.width, ws_h); - let detail_area = Rect::new(content.x, content.y + ws_h, content.width, detail_h); - (ws_area, detail_area) + let (workspace_height, detail_height) = sidebar_section_heights(content.height, split_ratio); + ( + Rect::new(content.x, content.y, content.width, workspace_height), + Rect::new( + content.x, + content.y + workspace_height, + content.width, + detail_height, + ), + ) } pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect { @@ -77,954 +71,38 @@ pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect return Rect::default(); } - let (ws_h, _) = sidebar_section_heights(content.height, split_ratio); - Rect::new(content.x, content.y + ws_h, content.width, 1) -} - -fn agent_panel_sort_label(sort: AgentPanelSort) -> &'static str { - match sort { - AgentPanelSort::Spaces => "grouped", - AgentPanelSort::Priority => "priority", - } -} - -pub(crate) fn agent_panel_toggle_rect(area: Rect, sort: AgentPanelSort) -> Rect { - agent_panel_header_label_rect(area, agent_panel_sort_label(sort)) -} - -fn agent_panel_header_label_rect(area: Rect, label: &str) -> Rect { - if area.width == 0 || area.height < 2 { - return Rect::default(); - } - - let width = display_width_u16(label).min(area.width); - Rect::new( - area.x + area.width.saturating_sub(width), - area.y + 1, - width, - 1, - ) -} - -fn active_agent_view_label(app: &AppState) -> Option<&str> { - app.agent_view_override - .as_ref() - .map(|view| view.label.as_deref().unwrap_or("filtered")) -} - -pub(crate) fn agent_panel_entries(app: &AppState) -> Vec { - agent_panel_entries_with_runtimes(app, None) -} - -pub(crate) fn all_agent_panel_entries(app: &AppState) -> Vec { - collect_agent_panel_entries_with_runtimes(app, None) + let (workspace_height, _) = sidebar_section_heights(content.height, split_ratio); + Rect::new(content.x, content.y + workspace_height, content.width, 1) } pub(crate) fn agent_panel_entries_from( app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, + _terminal_runtimes: &TerminalRuntimeRegistry, ) -> Vec { - agent_panel_entries_with_runtimes(app, Some(terminal_runtimes)) -} - -fn agent_panel_entries_with_runtimes( - app: &AppState, - terminal_runtimes: Option<&TerminalRuntimeRegistry>, -) -> Vec { - let mut entries = collect_agent_panel_entries_with_runtimes(app, terminal_runtimes); - crate::app::agent_view::apply_agent_view(app, &mut entries); - entries -} - -fn collect_agent_panel_entries_with_runtimes( - app: &AppState, - terminal_runtimes: Option<&TerminalRuntimeRegistry>, -) -> Vec { - let empty_runtimes; - let terminal_runtimes = match terminal_runtimes { - Some(terminal_runtimes) => terminal_runtimes, - None => { - empty_runtimes = TerminalRuntimeRegistry::new(); - &empty_runtimes - } - }; - - app.workspaces - .iter() - .enumerate() - .flat_map(|(ws_idx, ws)| { - let multi_tab = ws.tabs.len() > 1; - let workspace_label = ws.display_name_from(&app.terminals, terminal_runtimes); - ws.pane_details(&app.terminals) - .into_iter() - .map(move |detail| { - let show_tab = multi_tab - || ws - .tabs - .get(detail.tab_idx) - .is_some_and(|tab| !tab.is_auto_named()); - AgentPanelEntry { - ws_idx, - tab_idx: detail.tab_idx, - pane_id: detail.pane_id, - primary_label: workspace_label.clone(), - primary_tab_label: show_tab.then_some(detail.tab_label), - pane_label: detail.pane_label, - terminal_title: detail.terminal_title, - terminal_title_stripped: detail.terminal_title_stripped, - agent_label: Some(detail.agent_label), - agent_kind_label: detail.agent_kind_label, - agent: detail.agent, - state: detail.state, - seen: detail.seen, - last_agent_state_change_seq: detail.last_agent_state_change_seq, - state_labels: detail.state_labels, - tokens: detail.tokens, - } - }) - }) - .collect() -} - -pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static str { - match (state, seen) { - (AgentState::Idle, false) => "done", - (AgentState::Idle, true) => "idle", - (AgentState::Working, _) => "working", - (AgentState::Blocked, _) => "blocked", - (AgentState::Unknown, _) => "unknown", - } -} - -fn workspace_row_height(app: &AppState, ws: &crate::workspace::Workspace, indented: bool) -> u16 { - let (state, seen) = ws.aggregate_state(&app.terminals); - let label = if indented { - grouped_child_display_label( - &ws.display_name_from_terminals(&app.terminals), - ws.branch().as_deref(), - ws.custom_name.is_some(), - ) - } else { - ws.display_name_from_terminals(&app.terminals) - }; - let token_values = ws.metadata_tokens.values(); - tokens::space_rows( - &app.sidebar_spaces, - SpaceTokenContext { - workspace: &label, - branch: ws.branch().as_deref(), - state_text: state_label(state, seen), - ahead_behind: ws.git_ahead_behind(), - tokens: &token_values, - suppress_git_details: indented, - }, - ) - .len() - .max(1) - .min(u16::MAX as usize) as u16 -} - -fn workspace_row_height_in_body( - app: &AppState, - workspace: &crate::workspace::Workspace, - indented: bool, - body_height: u16, -) -> u16 { - workspace_row_height(app, workspace, indented).min(body_height) -} - -fn workspace_entry_gap(app: &AppState, entries: &[WorkspaceListEntry], entry_idx: usize) -> u16 { - if entry_idx + 1 < entries.len() && !next_entry_is_indented_workspace(entries, entry_idx) { - app.sidebar_spaces.row_gap - } else { - 0 - } -} - -fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 { - match (state, seen) { - (AgentState::Blocked, _) => 4, - (AgentState::Idle, false) => 3, - (AgentState::Working, _) => 2, - (AgentState::Idle, true) => 1, - (AgentState::Unknown, _) => 0, - } -} - -fn space_aggregate_state(app: &AppState, key: &str) -> (AgentState, bool) { - app.workspaces - .iter() - .filter(|ws| ws.worktree_space().is_some_and(|space| space.key == key)) - .map(|ws| ws.aggregate_state(&app.terminals)) - .max_by_key(|(state, seen)| workspace_attention_priority(*state, *seen)) - .unwrap_or((AgentState::Unknown, true)) -} - -pub(crate) fn workspace_parent_group_state( - app: &AppState, - ws_idx: usize, -) -> Option<(String, bool)> { - let space = app.workspaces.get(ws_idx)?.worktree_space()?; - if space.is_linked_worktree { - return None; - } - let member_count = app + let mut entries = app .workspaces .iter() - .filter(|ws| { - ws.worktree_space() - .is_some_and(|member| member.key == space.key) - }) - .count(); - (member_count >= 2).then(|| { - ( - space.key.clone(), - app.collapsed_space_keys.contains(&space.key), - ) - }) -} - -pub(crate) fn grouped_child_display_label( - label: &str, - branch: Option<&str>, - has_custom_name: bool, -) -> String { - if has_custom_name { - return label.to_string(); - } - let Some(branch) = branch else { - return label.to_string(); - }; - branch - .strip_prefix("worktree/") - .unwrap_or(branch) - .to_string() -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum WorkspaceListEntry { - Workspace { ws_idx: usize, indented: bool }, -} - -pub(crate) fn next_entry_is_indented_workspace(entries: &[WorkspaceListEntry], idx: usize) -> bool { - matches!( - entries.get(idx.saturating_add(1)), - Some(WorkspaceListEntry::Workspace { indented: true, .. }) - ) -} - -pub(crate) fn normalized_workspace_scroll(app: &AppState, area: Rect, requested: usize) -> usize { - let ws_area = workspace_list_rect(area, app.sidebar_section_split); - let body = workspace_list_body_rect(ws_area, false); - if body.height == 0 { - return requested; - } - - if workspace_list_entries(app).is_empty() { - 0 - } else { - requested.min(workspace_list_bottom_start(app, ws_area)) - } -} - -pub(crate) fn workspace_list_entries(app: &AppState) -> Vec { - workspace_list_entries_inner(app, false) -} - -/// Like [`workspace_list_entries`] but always expands worktree groups, ignoring -/// `collapsed_space_keys`. The mobile switcher has no collapse affordance and -/// always shows the full worktree tree. -pub(crate) fn workspace_list_entries_expanded(app: &AppState) -> Vec { - workspace_list_entries_inner(app, true) -} - -fn workspace_list_entries_inner(app: &AppState, force_expanded: bool) -> Vec { - let mut members_by_key = std::collections::HashMap::>::new(); - for (ws_idx, ws) in app.workspaces.iter().enumerate() { - if let Some(space) = ws.worktree_space() { - members_by_key - .entry(space.key.clone()) - .or_default() - .push(ws_idx); - } - } - let grouped_keys = members_by_key - .iter() - .filter(|(_, members)| { - members.len() >= 2 - && members.iter().any(|idx| { - app.workspaces - .get(*idx) - .and_then(|ws| ws.worktree_space()) - .is_some_and(|space| !space.is_linked_worktree) + .enumerate() + .flat_map(|(ws_idx, workspace)| { + workspace + .pane_details(&app.terminals) + .into_iter() + .map(move |detail| AgentPanelEntry { + ws_idx, + tab_idx: detail.tab_idx, + pane_id: detail.pane_id, + agent_kind_label: detail.agent_kind_label, + state: detail.state, + seen: detail.seen, + last_agent_state_change_seq: detail.last_agent_state_change_seq, + tokens: detail.tokens, }) }) - .map(|(key, _)| key.clone()) - .collect::>(); - - let visible_group_idx = if matches!(app.mode, Mode::Navigate) { - Some(app.selected) - } else { - app.active - }; - let active_group = visible_group_idx.and_then(|idx| { - app.workspaces - .get(idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.clone()) - }); - - let mut emitted_groups = std::collections::HashSet::::new(); - let mut entries = Vec::new(); - for (ws_idx, ws) in app.workspaces.iter().enumerate() { - let Some(space) = ws - .worktree_space() - .filter(|space| grouped_keys.contains(&space.key)) - else { - entries.push(WorkspaceListEntry::Workspace { - ws_idx, - indented: false, - }); - continue; - }; - - if !emitted_groups.insert(space.key.clone()) { - continue; - } - - let Some(members) = members_by_key.get(&space.key) else { - continue; - }; - let Some(parent_idx) = members.iter().copied().find(|idx| { - app.workspaces - .get(*idx) - .and_then(|member| member.worktree_space()) - .is_some_and(|member_space| !member_space.is_linked_worktree) - }) else { - entries.push(WorkspaceListEntry::Workspace { - ws_idx, - indented: false, - }); - continue; - }; - let collapsed = !force_expanded && app.collapsed_space_keys.contains(&space.key); - entries.push(WorkspaceListEntry::Workspace { - ws_idx: parent_idx, - indented: false, - }); - - if collapsed { - if let Some(active_idx) = visible_group_idx - .filter(|idx| *idx != parent_idx) - .filter(|_| active_group.as_deref() == Some(space.key.as_str())) - { - entries.push(WorkspaceListEntry::Workspace { - ws_idx: active_idx, - indented: true, - }); - } - } else { - for member_idx in members { - if *member_idx == parent_idx { - continue; - } - entries.push(WorkspaceListEntry::Workspace { - ws_idx: *member_idx, - indented: true, - }); - } - } - } + .collect(); + crate::app::agent_view::apply_agent_view(app, &mut entries); entries } -pub(crate) fn workspace_list_rect(area: Rect, split_ratio: f32) -> Rect { - let (ws_area, _) = expanded_sidebar_sections(area, split_ratio); - ws_area -} - -pub(crate) fn workspace_list_body_rect(area: Rect, has_scrollbar: bool) -> Rect { - if area.width == 0 || area.height <= WORKSPACE_SECTION_HEADER_ROWS { - return Rect::default(); - } - - let body_y = area.y.saturating_add(WORKSPACE_SECTION_HEADER_ROWS); - let footer_y = area.y + area.height.saturating_sub(1); - let body_height = footer_y.saturating_sub(body_y); - let body_width = area.width.saturating_sub(u16::from(has_scrollbar)); - Rect::new(area.x, body_y, body_width, body_height) -} - -fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> usize { - let body = workspace_list_body_rect(area, false); - if body.width == 0 || body.height == 0 { - return 0; - } - - let mut used_rows = 0u16; - let mut visible = 0usize; - let entries = workspace_list_entries(app); - for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) { - let (row_height, gap) = match entry { - WorkspaceListEntry::Workspace { ws_idx, indented } => { - let Some(ws) = app.workspaces.get(*ws_idx) else { - continue; - }; - ( - workspace_row_height_in_body(app, ws, *indented, body.height), - workspace_entry_gap(app, &entries, entry_idx), - ) - } - }; - if used_rows.saturating_add(row_height) > body.height { - break; - } - used_rows = used_rows.saturating_add(row_height); - visible += 1; - used_rows = used_rows.saturating_add(gap).min(body.height); - } - visible -} - -fn workspace_list_bottom_start(app: &AppState, area: Rect) -> usize { - let body = workspace_list_body_rect(area, false); - let entries = workspace_list_entries(app); - let mut used_rows = 0u16; - let mut start = entries.len(); - for (entry_idx, entry) in entries.iter().enumerate().rev() { - let WorkspaceListEntry::Workspace { ws_idx, indented } = entry; - let Some(workspace) = app.workspaces.get(*ws_idx) else { - continue; - }; - let gap = workspace_entry_gap(app, &entries, entry_idx); - let needed = workspace_row_height_in_body(app, workspace, *indented, body.height) - .saturating_add(gap); - if used_rows.saturating_add(needed) > body.height { - break; - } - used_rows = used_rows.saturating_add(needed); - start = entry_idx; - } - start.min(entries.len().saturating_sub(1)) -} - -pub(crate) fn workspace_list_scroll_metrics( - app: &AppState, - area: Rect, -) -> crate::pane::ScrollMetrics { - let max_scroll = workspace_list_bottom_start(app, area); - let scroll = app.workspace_scroll.min(max_scroll); - let viewport_rows = workspace_list_visible_count(app, area, scroll); - - crate::pane::ScrollMetrics { - offset_from_bottom: max_scroll.saturating_sub(scroll), - max_offset_from_bottom: max_scroll, - viewport_rows, - } -} - -pub(crate) fn workspace_list_scrollbar_rect(app: &AppState, area: Rect) -> Option { - let metrics = workspace_list_scroll_metrics(app, area); - let body = workspace_list_body_rect(area, true); - (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new( - area.x + area.width.saturating_sub(1), - body.y, - 1, - body.height, - )) -} - -pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect { - if area.width == 0 || area.height <= AGENT_PANEL_HEADER_ROWS { - return Rect::default(); - } - - let body_y = area.y.saturating_add(AGENT_PANEL_HEADER_ROWS); - let body_height = (area.y + area.height).saturating_sub(body_y); - let body_width = area.width.saturating_sub(u16::from(has_scrollbar)); - Rect::new(area.x, body_y, body_width, body_height) -} - -fn resolved_agent_rows(app: &AppState, entry: &AgentPanelEntry) -> Vec> { - let label = entry - .state_labels - .get(agent_panel_status_key(entry.state, entry.seen)) - .map(String::as_str) - .unwrap_or_else(|| state_label(entry.state, entry.seen)); - tokens::agent_rows( - &app.sidebar_agents, - AgentTokenContext { - workspace: &entry.primary_label, - tab: entry.primary_tab_label.as_deref(), - pane: entry.pane_label.as_deref(), - agent_label: entry.agent_label.as_deref(), - terminal_title: entry.terminal_title.as_deref(), - terminal_title_stripped: entry.terminal_title_stripped.as_deref(), - canonical_agent: entry.agent, - tokens: &entry.tokens, - }, - label, - ) -} - -pub(crate) fn agent_entry_height_in_body( - app: &AppState, - entry: &AgentPanelEntry, - body_height: u16, -) -> u16 { - (resolved_agent_rows(app, entry) - .len() - .max(1) - .min(u16::MAX as usize) as u16) - .min(body_height) -} - -pub(crate) fn agent_entry_gap(app: &AppState, entry_idx: usize, entry_count: usize) -> u16 { - if entry_idx + 1 < entry_count { - app.sidebar_agents.row_gap - } else { - 0 - } -} - -fn agent_panel_visible_count_from(app: &AppState, area: Rect, scroll: usize) -> usize { - let body = agent_panel_body_rect(area, false); - if body.width == 0 || body.height == 0 { - return 0; - } - - let mut used_rows = 0u16; - let mut visible = 0usize; - let entries = agent_panel_entries(app); - for (index, entry) in entries.iter().enumerate().skip(scroll) { - let height = agent_entry_height_in_body(app, entry, body.height); - if used_rows.saturating_add(height) > body.height { - break; - } - used_rows = used_rows.saturating_add(height); - visible += 1; - used_rows = used_rows - .saturating_add(agent_entry_gap(app, index, entries.len())) - .min(body.height); - } - visible -} - -fn agent_panel_bottom_start(app: &AppState, area: Rect) -> usize { - let body = agent_panel_body_rect(area, false); - let entries = agent_panel_entries(app); - let mut used_rows = 0u16; - let mut start = entries.len(); - for (index, entry) in entries.iter().enumerate().rev() { - let gap = agent_entry_gap(app, index, entries.len()); - let needed = agent_entry_height_in_body(app, entry, body.height).saturating_add(gap); - if used_rows.saturating_add(needed) > body.height { - break; - } - used_rows = used_rows.saturating_add(needed); - start = index; - } - start.min(entries.len().saturating_sub(1)) -} - -pub(crate) fn agent_panel_scroll_for_target( - app: &AppState, - area: Rect, - current_scroll: usize, - target: usize, -) -> usize { - let max_scroll = agent_panel_bottom_start(app, area); - if target < current_scroll { - return target.min(max_scroll); - } - let mut scroll = current_scroll.min(max_scroll); - while scroll < target { - let visible = agent_panel_visible_count_from(app, area, scroll); - if visible > 0 && target < scroll.saturating_add(visible) { - break; - } - scroll += 1; - } - scroll.min(max_scroll) -} - -pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics { - let max_scroll = agent_panel_bottom_start(app, area); - let scroll = app.agent_panel_scroll.min(max_scroll); - let viewport_rows = agent_panel_visible_count_from(app, area, scroll); - - crate::pane::ScrollMetrics { - offset_from_bottom: max_scroll.saturating_sub(scroll), - max_offset_from_bottom: max_scroll, - viewport_rows, - } -} - -pub(crate) fn agent_panel_scrollbar_rect(app: &AppState, area: Rect) -> Option { - let metrics = agent_panel_scroll_metrics(app, area); - let body = agent_panel_body_rect(area, true); - (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new( - area.x + area.width.saturating_sub(1), - body.y, - 1, - body.height, - )) -} - -pub(crate) fn compute_workspace_list_areas( - app: &AppState, - area: Rect, -) -> (Vec, Vec<()>) { - let ws_area = workspace_list_rect(area, app.sidebar_section_split); - if ws_area == Rect::default() { - return (Vec::new(), Vec::new()); - } - - let metrics = workspace_list_scroll_metrics(app, ws_area); - let body = workspace_list_body_rect(ws_area, should_show_scrollbar(metrics)); - if body.width == 0 || body.height == 0 { - return (Vec::new(), Vec::new()); - } - - let scroll = app.workspace_scroll; - let mut row_y = body.y; - let body_bottom = body.y + body.height; - let mut cards = Vec::new(); - let headers = Vec::new(); - - let entries = workspace_list_entries(app); - for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) { - match entry { - WorkspaceListEntry::Workspace { ws_idx, indented } => { - let Some(ws) = app.workspaces.get(*ws_idx) else { - continue; - }; - let row_height = workspace_row_height_in_body(app, ws, *indented, body.height); - let gap = workspace_entry_gap(app, &entries, entry_idx); - if row_y.saturating_add(row_height) > body_bottom { - break; - } - cards.push(crate::app::state::WorkspaceCardArea { - ws_idx: *ws_idx, - rect: Rect::new(body.x, row_y, body.width, row_height), - indented: *indented, - }); - row_y = row_y - .saturating_add(row_height) - .saturating_add(gap) - .min(body_bottom); - } - } - } - - (cards, headers) -} - -pub(crate) fn compute_workspace_card_areas( - app: &AppState, - area: Rect, -) -> Vec { - compute_workspace_list_areas(app, area).0 -} - -pub(crate) fn workspace_group_chevron_rect(card: &crate::app::state::WorkspaceCardArea) -> Rect { - if card.rect.width == 0 || card.rect.height == 0 { - return Rect::default(); - } - - Rect::new( - card.rect.x + card.rect.width.saturating_sub(1), - card.rect.y, - 1, - 1, - ) -} - -/// Auto-scale sidebar width based on workspace identity + agent summary. -pub(crate) fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option, Rect) { - let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); - if content.width == 0 || content.height == 0 { - return (Rect::default(), None, Rect::default()); - } - - if content.height < 7 { - return (content, None, Rect::default()); - } - - let total_h = content.height as usize; - let ws_h = total_h.div_ceil(2); - let detail_h = total_h.saturating_sub(ws_h + 1); - if ws_h == 0 || detail_h == 0 { - return (content, None, Rect::default()); - } - - let divider_y = content.y + ws_h as u16; - let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16); - let detail_area = Rect::new(content.x, divider_y + 1, content.width, detail_h as u16); - (ws_area, Some(divider_y), detail_area) -} - -fn workspace_selection_background(p: &Palette, is_active: bool) -> Color { - if is_active && p.selection_bg == Color::Reset { - p.active_row_bg - } else { - p.selection_bg - } -} - -/// Collapsed sidebar: workspace glance on top, compact agent list below. -pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { - if area.width == 0 || area.height == 0 { - return; - } - - let is_navigating = matches!(app.mode, Mode::Navigate); - - let p = &app.palette; - frame - .buffer_mut() - .set_style(area, Style::default().bg(p.sidebar_bg)); - let sep_style = if is_navigating { - Style::default().fg(p.accent) - } else { - Style::default().fg(p.surface_dim) - }; - let sep_x = area.x + area.width.saturating_sub(1); - let buf = frame.buffer_mut(); - for y in area.y..area.y + area.height { - buf[(sep_x, y)].set_symbol("│"); - buf[(sep_x, y)].set_style(sep_style); - } - - let (ws_area, divider_y, detail_area) = collapsed_sidebar_sections(area); - if ws_area == Rect::default() { - render_sidebar_toggle(app, frame, area, true, p); - return; - } - - for (visible_idx, ws) in app.workspaces.iter().enumerate() { - let y = ws_area.y + visible_idx as u16; - if y >= ws_area.y + ws_area.height { - break; - } - let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals); - let (icon, icon_style) = state_icon(agg_state, agg_seen, app.status_indicators, p); - let is_selected = visible_idx == app.selected && is_navigating; - let is_active = Some(visible_idx) == app.active; - let selection_bg = workspace_selection_background(p, is_active); - let row_style = if is_selected { - Style::default().bg(selection_bg) - } else if is_active { - Style::default().bg(p.active_row_bg) - } else { - Style::default() - }; - let num_style = if is_selected { - Style::default().fg(p.overlay1).bg(selection_bg) - } else if is_active { - Style::default().fg(p.text).bg(p.active_row_bg) - } else { - Style::default().fg(p.overlay0) - }; - - if is_selected || is_active { - let buf = frame.buffer_mut(); - for x in ws_area.x..ws_area.x + ws_area.width { - buf[(x, y)].set_style(row_style); - } - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(format!("{:<2}", visible_idx + 1), num_style), - Span::styled(icon, icon_style), - ])), - Rect::new(ws_area.x, y, ws_area.width, 1), - ); - } - - if let Some(divider_y) = divider_y { - let buf = frame.buffer_mut(); - let divider_color = if app.agent_view_override.is_some() { - p.accent - } else { - p.surface_dim - }; - for x in ws_area.x..ws_area.x + ws_area.width { - buf[(x, divider_y)].set_symbol("─"); - buf[(x, divider_y)].set_style(Style::default().fg(divider_color)); - } - } - - let detail_content_area = Rect::new( - detail_area.x, - detail_area.y, - detail_area.width, - detail_area.height.saturating_sub(1), - ); - if detail_content_area != Rect::default() { - for (detail_idx, detail) in agent_panel_entries(app).iter().enumerate() { - let y = detail_content_area.y + detail_idx as u16; - if y >= detail_content_area.y + detail_content_area.height { - break; - } - let position = detail_idx + 1; - let is_active = app.is_active_pane(detail.ws_idx, detail.tab_idx, detail.pane_id); - let position_style = if is_active { - Style::default().fg(p.text).bg(p.active_row_bg) - } else { - Style::default().fg(p.overlay0) - }; - let (icon, icon_style) = - state_icon(detail.state, detail.seen, app.status_indicators, p); - - if is_active { - let buf = frame.buffer_mut(); - for x in detail_content_area.x..detail_content_area.x + detail_content_area.width { - buf[(x, y)].set_style(Style::default().bg(p.active_row_bg)); - } - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(format!("{position:<2}"), position_style), - Span::styled(icon, icon_style), - ])), - Rect::new(detail_content_area.x, y, detail_content_area.width, 1), - ); - } - } - - render_sidebar_toggle(app, frame, area, true, p); -} - -pub(crate) fn workspace_drop_slots( - app: &AppState, - cards: &[crate::app::state::WorkspaceCardArea], - area: Rect, -) -> Vec<(crate::app::state::WorkspaceDropTarget, u16)> { - if area.height == 0 || cards.is_empty() { - return Vec::new(); - } - let list_bottom = area.y + area.height.saturating_sub(1); - let entries = workspace_list_entries(app); - let entry_position = |ws_idx| { - entries.iter().position(|entry| { - matches!( - entry, - WorkspaceListEntry::Workspace { - ws_idx: entry_ws_idx, - .. - } if *entry_ws_idx == ws_idx - ) - }) - }; - let block_root_at = |entry_idx: usize| { - entries[..=entry_idx] - .iter() - .rev() - .find_map(|entry| match entry { - WorkspaceListEntry::Workspace { - ws_idx, - indented: false, - } => Some(*ws_idx), - WorkspaceListEntry::Workspace { .. } => None, - }) - }; - - let mut slots = Vec::new(); - let mut previous_root = None; - for card in cards { - let Some(entry_idx) = entry_position(card.ws_idx) else { - continue; - }; - let Some(root_idx) = block_root_at(entry_idx) else { - continue; - }; - if previous_root == Some(root_idx) { - continue; - } - previous_root = Some(root_idx); - if let Some(row) = card.rect.y.checked_sub(1).filter(|row| *row < list_bottom) { - slots.push(( - crate::app::state::WorkspaceDropTarget::Before(root_idx), - row, - )); - } - } - - let Some(last) = cards.last() else { - return slots; - }; - let Some(last_entry_idx) = entry_position(last.ws_idx) else { - return slots; - }; - let next_entry = entries.get(last_entry_idx.saturating_add(1)); - if matches!( - next_entry, - Some(WorkspaceListEntry::Workspace { indented: true, .. }) - ) { - return slots; - } - let target = match next_entry { - Some(WorkspaceListEntry::Workspace { ws_idx, .. }) => { - crate::app::state::WorkspaceDropTarget::Before(*ws_idx) - } - None => crate::app::state::WorkspaceDropTarget::End, - }; - let row = last.rect.y.saturating_add(last.rect.height); - if row < list_bottom - && slots - .last() - .is_none_or(|(last_target, _)| *last_target != target) - { - slots.push((target, row)); - } - slots -} - -pub(crate) fn workspace_drop_indicator_row( - app: &AppState, - cards: &[crate::app::state::WorkspaceCardArea], - area: Rect, - target: crate::app::state::WorkspaceDropTarget, -) -> Option { - workspace_drop_slots(app, cards, area) - .into_iter() - .find_map(|(candidate, row)| (candidate == target).then_some(row)) -} - -pub(super) fn render_sidebar( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - let p = &app.palette; - frame - .buffer_mut() - .set_style(area, Style::default().bg(p.sidebar_bg)); - let is_navigating = matches!(app.mode, Mode::Navigate); - let sep_style = if is_navigating { - Style::default().fg(p.accent) - } else { - Style::default().fg(p.surface_dim) - }; - - let sep_x = area.x + area.width.saturating_sub(1); - let buf = frame.buffer_mut(); - for y in area.y..area.y + area.height { - buf[(sep_x, y)].set_symbol("│"); - buf[(sep_x, y)].set_style(sep_style); - } - - let (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - - render_workspace_list(app, terminal_runtimes, frame, ws_area, is_navigating); - render_agent_detail(app, terminal_runtimes, frame, detail_area); - render_sidebar_toggle(app, frame, area, false, p); -} - pub(crate) fn resolved_token_spans( resolved: &[ResolvedToken], state_icon: (&str, Style), @@ -1032,7 +110,7 @@ pub(crate) fn resolved_token_spans( workspace_style: Style, secondary_style: Style, custom_style: Style, - p: &Palette, + palette: &Palette, max_width: usize, ) -> Vec> { let fixed_widths = resolved @@ -1132,6 +210,7 @@ pub(crate) fn resolved_token_spans( break; } } + let mut spans = Vec::new(); for (position, index) in visible_indices.iter().copied().enumerate() { let token = &resolved[index]; @@ -1139,42 +218,36 @@ pub(crate) fn resolved_token_spans( let previous = &resolved[visible_indices[position - 1]]; spans.push(Span::styled( tokens::separator(previous, token), - Style::default().fg(p.overlay0).add_modifier(Modifier::DIM), + Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::DIM), )); } match &token.kind { - ResolvedTokenKind::StateIcon => { - spans.push(Span::styled( - state_icon.0.to_string(), - apply_token_style(state_icon.1, token.style), - )); - } - ResolvedTokenKind::StateText(text) => { - spans.push(Span::styled( - truncate_end(text, budgets[index]), - apply_token_style(state_text_style, token.style), - )); - } - ResolvedTokenKind::Workspace(text) => { - spans.push(Span::styled( - truncate_end(text, budgets[index]), - apply_token_style(workspace_style, token.style), - )); - } + ResolvedTokenKind::StateIcon => spans.push(Span::styled( + state_icon.0.to_string(), + apply_token_style(state_icon.1, token.style), + )), + ResolvedTokenKind::StateText(text) => spans.push(Span::styled( + truncate_end(text, budgets[index]), + apply_token_style(state_text_style, token.style), + )), + ResolvedTokenKind::Workspace(text) => spans.push(Span::styled( + truncate_end(text, budgets[index]), + apply_token_style(workspace_style, token.style), + )), ResolvedTokenKind::Tab(text) | ResolvedTokenKind::Pane(text) | ResolvedTokenKind::Agent(text) - | ResolvedTokenKind::Branch(text) => { - spans.push(Span::styled( - truncate_end(text, budgets[index]), - apply_token_style(secondary_style, token.style), - )); - } + | ResolvedTokenKind::Branch(text) => spans.push(Span::styled( + truncate_end(text, budgets[index]), + apply_token_style(secondary_style, token.style), + )), ResolvedTokenKind::GitStatus { ahead, behind } => { if *ahead > 0 { spans.push(Span::styled( format!("↑{ahead}"), - apply_token_style(Style::default().fg(p.green), token.style), + apply_token_style(Style::default().fg(palette.green), token.style), )); } if *ahead > 0 && *behind > 0 { @@ -1186,7 +259,7 @@ pub(crate) fn resolved_token_spans( if *behind > 0 { spans.push(Span::styled( format!("↓{behind}"), - apply_token_style(Style::default().fg(p.red), token.style), + apply_token_style(Style::default().fg(palette.red), token.style), )); } } @@ -1202,8 +275,8 @@ pub(crate) fn resolved_token_spans( } fn apply_token_style(mut style: Style, patch: crate::config::SidebarTokenStyle) -> Style { - if let Some(fg) = patch.fg { - style = style.fg(fg.ratatui()); + if let Some(foreground) = patch.fg { + style = style.fg(foreground.ratatui()); } if let Some(bold) = patch.bold { style = if bold { @@ -1221,1974 +294,3 @@ fn apply_token_style(mut style: Style, patch: crate::config::SidebarTokenStyle) } style } - -fn render_workspace_list( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, - is_navigating: bool, -) { - let p = &app.palette; - let dragged_ws_idx = match app.drag.as_ref().map(|drag| &drag.target) { - Some(crate::app::state::DragTarget::WorkspaceReorder { source_ws_idx, .. }) => { - Some(*source_ws_idx) - } - _ => None, - }; - let insertion_row = match app.drag.as_ref().map(|drag| &drag.target) { - Some(crate::app::state::DragTarget::WorkspaceReorder { - drop_target: Some(drop_target), - .. - }) => workspace_drop_indicator_row(app, &app.view.workspace_card_areas, area, *drop_target), - _ => None, - }; - - let list_bottom = area.y + area.height.saturating_sub(1); - if area.height > 0 { - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " spaces", - Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), - )])), - Rect::new(area.x, area.y, area.width, 1), - ); - } - - let metrics = workspace_list_scroll_metrics(app, area); - let scrollbar_rect = workspace_list_scrollbar_rect(app, area); - let cards = &app.view.workspace_card_areas; - let entries = workspace_list_entries(app); - - for card in cards { - let i = card.ws_idx; - let ws = &app.workspaces[i]; - let row_y = card.rect.y; - let row_height = card.rect.height; - let selected = i == app.selected && is_navigating; - let is_active = Some(i) == app.active; - let is_dragged = dragged_ws_idx == Some(i); - let highlighted = selected || is_active || is_dragged; - let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals); - - if highlighted { - let bg = if selected { - workspace_selection_background(p, is_active) - } else if is_dragged { - p.surface1 - } else { - p.active_row_bg - }; - let buf = frame.buffer_mut(); - for y in row_y..row_y + row_height { - if y >= list_bottom { - break; - } - for x in card.rect.x..card.rect.x + card.rect.width { - buf[(x, y)].set_style(Style::default().bg(bg)); - } - } - } - - let name_style = if selected || is_active || is_dragged { - Style::default().fg(p.text).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0) - }; - - let label = ws.display_name_from(&app.terminals, terminal_runtimes); - let display_label = if card.indented { - grouped_child_display_label(&label, ws.branch().as_deref(), ws.custom_name.is_some()) - } else { - label - }; - let parent_group = (!card.indented) - .then(|| workspace_parent_group_state(app, i)) - .flatten(); - let is_last_child = card.indented - && entries - .iter() - .position(|entry| { - matches!( - entry, - WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == i - ) - }) - .is_none_or(|entry_idx| !next_entry_is_indented_workspace(&entries, entry_idx)); - let (display_state, display_seen) = parent_group - .as_ref() - .filter(|(_, collapsed)| *collapsed) - .map(|(key, _)| space_aggregate_state(app, key)) - .unwrap_or((agg_state, agg_seen)); - let state_icon = state_icon(display_state, display_seen, app.status_indicators, p); - let state_text_style = Style::default() - .fg(state_label_color(display_state, display_seen, p)) - .add_modifier(Modifier::DIM); - let branch_style = Style::default().fg(if selected || is_active { - p.mauve - } else { - p.overlay0 - }); - let token_values = ws.metadata_tokens.values(); - let rows = tokens::space_rows( - &app.sidebar_spaces, - SpaceTokenContext { - workspace: &display_label, - branch: ws.branch().as_deref(), - state_text: state_label(display_state, display_seen), - ahead_behind: ws.git_ahead_behind(), - tokens: &token_values, - suppress_git_details: card.indented, - }, - ); - - for (row_index, resolved) in rows.iter().enumerate() { - if row_index as u16 >= row_height || row_y + row_index as u16 >= list_bottom { - break; - } - let mut spans = Vec::new(); - let prefix_width = if card.indented { - spans.push(Span::raw(" ")); - if row_index == 0 { - spans.push(Span::styled( - if is_last_child { "└─ " } else { "├─ " }, - Style::default().fg(p.overlay0), - )); - 6 - } else if is_last_child { - spans.push(Span::raw(" ")); - 8 - } else { - spans.push(Span::styled("│", Style::default().fg(p.overlay0))); - spans.push(Span::raw(" ")); - 8 - } - } else if row_index == 0 { - spans.push(Span::raw(" ")); - 1 - } else { - spans.push(Span::raw(" ")); - 3 - }; - let trailing_width = if row_index == 0 && parent_group.is_some() { - 2 - } else { - 0 - }; - spans.extend(resolved_token_spans( - resolved, - state_icon, - state_text_style, - name_style, - branch_style, - branch_style, - p, - card.rect - .width - .saturating_sub(prefix_width + trailing_width) as usize, - )); - frame.render_widget( - Paragraph::new(Line::from(spans)), - Rect::new(card.rect.x, row_y + row_index as u16, card.rect.width, 1), - ); - } - - if let Some((_, collapsed)) = parent_group { - frame.render_widget( - Paragraph::new(Span::styled( - if collapsed { "▸" } else { "▾" }, - Style::default().fg(p.accent), - )), - workspace_group_chevron_rect(card), - ); - } - } - - if let Some(y) = insertion_row.filter(|y| *y < list_bottom) { - let indicator_right = scrollbar_rect - .map(|rect| rect.x) - .unwrap_or(area.x + area.width); - let buf = frame.buffer_mut(); - for x in area.x..indicator_right { - buf[(x, y)].set_symbol("─"); - buf[(x, y)].set_style(Style::default().fg(p.accent)); - } - } - - if let Some(track) = scrollbar_rect { - render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕"); - } - - if app.mouse_capture && list_bottom > area.y { - let new_rect = app.sidebar_new_button_rect(); - frame.render_widget( - Paragraph::new(Span::styled(" new", Style::default().fg(p.overlay0))), - new_rect, - ); - - let menu_rect = app.global_launcher_rect(); - let menu_line = if app.global_menu_attention_badge_visible() { - Line::from(vec![ - Span::styled( - "● ", - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - Span::styled("menu", Style::default().fg(p.overlay0)), - ]) - } else { - Line::from(vec![Span::styled("menu", Style::default().fg(p.overlay0))]) - }; - frame.render_widget( - Paragraph::new(menu_line).alignment(Alignment::Right), - menu_rect, - ); - } -} - -fn render_agent_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - let p = &app.palette; - - if area.height < 3 { - return; - } - - let sep_line = "─".repeat(area.width as usize); - frame.render_widget( - Paragraph::new(Span::styled(&sep_line, Style::default().fg(p.surface_dim))), - Rect::new(area.x, area.y, area.width, 1), - ); - - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " agents", - Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), - )])), - Rect::new(area.x, area.y + 1, area.width, 1), - ); - let control_label = active_agent_view_label(app) - .unwrap_or_else(|| agent_panel_sort_label(app.agent_panel_sort)); - let toggle_rect = agent_panel_header_label_rect(area, control_label); - if toggle_rect != Rect::default() { - let color = if app.agent_view_override.is_some() { - p.accent - } else { - p.overlay0 - }; - frame.render_widget( - Paragraph::new(Span::styled( - control_label, - Style::default().fg(color).add_modifier(Modifier::BOLD), - )) - .alignment(Alignment::Right), - toggle_rect, - ); - } - - let details = agent_panel_entries_from(app, terminal_runtimes); - let metrics = agent_panel_scroll_metrics(app, area); - let scrollbar_rect = agent_panel_scrollbar_rect(app, area); - let body = agent_panel_body_rect(area, should_show_scrollbar(metrics)); - if body == Rect::default() { - return; - } - if details.is_empty() && app.agent_view_override.is_some() { - frame.render_widget( - Paragraph::new(" no matching agents") - .style(Style::default().fg(p.overlay0).add_modifier(Modifier::DIM)), - Rect::new(body.x, body.y, body.width, 1), - ); - return; - } - - let scroll = app.agent_panel_scroll.min(metrics.max_offset_from_bottom); - let mut row_y = body.y; - let body_bottom = body.y + body.height; - for (index, detail) in details.iter().enumerate().skip(scroll) { - let label_color = state_label_color(detail.state, detail.seen, p); - let rows = resolved_agent_rows(app, detail); - let height = (rows.len().max(1) as u16).min(body.height); - if row_y.saturating_add(height) > body_bottom { - break; - } - - let is_active = app.is_active_pane(detail.ws_idx, detail.tab_idx, detail.pane_id); - let row_style = if is_active { - Style::default().bg(p.active_row_bg) - } else { - Style::default() - }; - let name_style = if is_active { - Style::default().fg(p.text).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD) - }; - let status_style = if is_active { - Style::default().fg(label_color) - } else { - Style::default().fg(label_color).add_modifier(Modifier::DIM) - }; - let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM); - let state_icon = state_icon(detail.state, detail.seen, app.status_indicators, p); - - for (row_index, resolved) in rows.iter().take(height as usize).enumerate() { - let mut spans = vec![Span::raw(if row_index == 0 { " " } else { " " })]; - spans.extend(resolved_token_spans( - resolved, - state_icon, - status_style, - name_style, - agent_style, - agent_style, - p, - body.width - .saturating_sub(if row_index == 0 { 1 } else { 3 }) as usize, - )); - frame.render_widget( - Paragraph::new(Line::from(spans)).style(row_style), - Rect::new(body.x, row_y + row_index as u16, body.width, 1), - ); - } - row_y = row_y - .saturating_add(height) - .saturating_add(agent_entry_gap(app, index, details.len())) - .min(body_bottom); - } - - if let Some(track) = scrollbar_rect { - render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕"); - } -} - -pub(crate) fn collapsed_sidebar_toggle_rect(area: Rect) -> Rect { - let bottom_y = area.y + area.height.saturating_sub(1); - let content_w = area.width.saturating_sub(1); - if content_w == 0 || area.height == 0 { - return Rect::default(); - } - let x = area.x + content_w / 2; - Rect::new(x, bottom_y, 1, 1) -} - -pub(crate) fn expanded_sidebar_toggle_rect(area: Rect) -> Rect { - if area.width <= 1 || area.height == 0 { - return Rect::default(); - } - Rect::new( - area.x + area.width.saturating_sub(2), - area.y + area.height.saturating_sub(1), - 1, - 1, - ) -} - -fn render_sidebar_toggle( - app: &AppState, - frame: &mut Frame, - area: Rect, - collapsed: bool, - p: &Palette, -) { - let toggle_area = if collapsed { - collapsed_sidebar_toggle_rect(area) - } else { - expanded_sidebar_toggle_rect(area) - }; - if toggle_area == Rect::default() { - return; - } - let icon = if collapsed { "»" } else { "«" }; - let icon_style = if collapsed && app.global_menu_attention_badge_visible() { - Style::default().fg(p.accent).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.overlay0) - }; - frame.render_widget(Paragraph::new(Span::styled(icon, icon_style)), toggle_area); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{detect::Agent, layout::PaneId, workspace::Workspace}; - use ratatui::{backend::TestBackend, layout::Direction, Terminal}; - - fn row_text(buffer: &ratatui::buffer::Buffer, row: u16, width: u16) -> String { - (0..width) - .map(|x| buffer[(x, row)].symbol()) - .collect::() - .trim_end() - .to_string() - } - - fn find_symbol_x(buffer: &ratatui::buffer::Buffer, row: u16, width: u16, symbol: &str) -> u16 { - (0..width) - .find(|x| buffer[(*x, row)].symbol() == symbol) - .unwrap_or_else(|| { - panic!( - "missing symbol {symbol:?} in row {}", - row_text(buffer, row, width) - ) - }) - } - - #[test] - fn expanded_and_collapsed_sidebars_use_custom_background() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces.clear(); - app.active = None; - app.palette.sidebar_bg = ratatui::style::Color::Rgb(12, 34, 56); - let area = Rect::new(0, 0, 26, 20); - - let mut expanded = Terminal::new(TestBackend::new(26, 20)).unwrap(); - expanded - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - assert!(expanded - .backend() - .buffer() - .content - .iter() - .all(|cell| cell.bg == app.palette.sidebar_bg)); - - let mut collapsed = Terminal::new(TestBackend::new(26, 20)).unwrap(); - collapsed - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .unwrap(); - assert!(collapsed - .backend() - .buffer() - .content - .iter() - .all(|cell| cell.bg == app.palette.sidebar_bg)); - } - - #[test] - fn default_agent_rows_remove_redundant_state_text() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = Workspace::test_new("one"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.active = Some(0); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal_state = app.terminals.get_mut(&terminal_id).unwrap(); - terminal_state.detected_agent = Some(Agent::Pi); - terminal_state.state = AgentState::Working; - - let area = Rect::new(0, 0, 26, 20); - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - let body = agent_panel_body_rect(agent_area, false); - - let first = row_text(buffer, body.y, 25); - let second = row_text(buffer, body.y + 1, 25); - assert!(first.contains("one")); - assert_eq!(second, " pi"); - assert!(!first.contains("working")); - assert!(!second.contains("working")); - - let workspace_x = find_symbol_x(buffer, body.y, body.width, "o"); - let workspace_style = buffer[(workspace_x, body.y)].style(); - assert_eq!(workspace_style.fg, Some(app.palette.text)); - assert!(workspace_style.add_modifier.contains(Modifier::BOLD)); - assert!(!workspace_style.add_modifier.contains(Modifier::DIM)); - assert_eq!(workspace_style.bg, Some(app.palette.active_row_bg)); - - let agent_x = find_symbol_x(buffer, body.y + 1, body.width, "p"); - let agent_style = buffer[(agent_x, body.y + 1)].style(); - assert_eq!(agent_style.fg, Some(app.palette.overlay0)); - assert!(agent_style.add_modifier.contains(Modifier::DIM)); - assert!(!agent_style.add_modifier.contains(Modifier::BOLD)); - assert_eq!(agent_style.bg, Some(app.palette.active_row_bg)); - } - - #[test] - fn occurrence_false_removes_default_workspace_bold_and_agent_dim() { - let config: crate::config::Config = toml::from_str( - r##" -[ui.sidebar.agents] -rows = [[{ token = "workspace", bold = false }, { token = "agent", dim = false }]] -"##, - ) - .unwrap(); - let mut app = crate::app::state::AppState::test_new(); - app.sidebar_agents = config.ui.sidebar.agents; - let workspace = Workspace::test_new("one"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.active = Some(0); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi); - - let area = Rect::new(0, 0, 26, 20); - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - let body = agent_panel_body_rect(agent_area, false); - let buffer = terminal.backend().buffer(); - let workspace = buffer[(find_symbol_x(buffer, body.y, body.width, "o"), body.y)].style(); - let agent = buffer[(find_symbol_x(buffer, body.y, body.width, "p"), body.y)].style(); - - assert_eq!(workspace.fg, Some(app.palette.text)); - assert!(!workspace.add_modifier.contains(Modifier::BOLD)); - assert_eq!(agent.fg, Some(app.palette.overlay0)); - assert!(!agent.add_modifier.contains(Modifier::DIM)); - } - - #[test] - fn default_space_workspace_style_tracks_active_state() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(0); - app.mode = Mode::Terminal; - let area = Rect::new(0, 0, 26, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let first_row = app.view.workspace_card_areas[0].rect.y; - let second_row = app.view.workspace_card_areas[1].rect.y; - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - - let active = buffer[(find_symbol_x(buffer, first_row, 25, "o"), first_row)].style(); - assert_eq!(active.fg, Some(app.palette.text)); - assert!(active.add_modifier.contains(Modifier::BOLD)); - assert!(!active.add_modifier.contains(Modifier::DIM)); - assert_eq!(active.bg, Some(app.palette.active_row_bg)); - - let inactive = buffer[(find_symbol_x(buffer, second_row, 25, "t"), second_row)].style(); - assert_eq!(inactive.fg, Some(app.palette.subtext0)); - assert!(!inactive - .add_modifier - .intersects(Modifier::BOLD | Modifier::DIM)); - assert_eq!(inactive.bg, Some(ratatui::style::Color::Reset)); - } - - #[test] - fn navigate_selection_keeps_its_existing_background_beside_active_workspace() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(0); - app.selected = 1; - app.mode = Mode::Navigate; - let area = Rect::new(0, 0, 26, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let active_row = app.view.workspace_card_areas[0].rect.y; - let selected_row = app.view.workspace_card_areas[1].rect.y; - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - - assert_eq!( - buffer[(0, active_row)].bg, - app.palette.active_row_bg, - "active workspace should keep its dedicated background" - ); - assert_eq!( - buffer[(0, selected_row)].bg, - app.palette.selection_bg, - "navigate selection should use its dedicated cursor background" - ); - } - - #[test] - fn selected_active_workspace_resolves_expanded_background() { - let mut app = crate::app::state::AppState::test_new(); - app.palette = crate::app::state::Palette::terminal(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Navigate; - let area = Rect::new(0, 0, 26, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let active_row = app.view.workspace_card_areas[0].rect.y; - let inactive_row = app.view.workspace_card_areas[1].rect.y; - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - - assert_eq!( - terminal.backend().buffer()[(0, active_row)].bg, - app.palette.active_row_bg - ); - - app.selected = 1; - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(0, active_row)].bg, - app.palette.active_row_bg - ); - assert_eq!( - terminal.backend().buffer()[(0, inactive_row)].bg, - app.palette.selection_bg - ); - - app.palette = crate::app::state::Palette::catppuccin(); - app.selected = 0; - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(0, active_row)].bg, - app.palette.selection_bg - ); - } - - #[test] - fn selected_active_workspace_resolves_collapsed_background() { - let mut app = crate::app::state::AppState::test_new(); - app.palette = crate::app::state::Palette::terminal(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Navigate; - let area = Rect::new(0, 0, 5, 8); - let mut terminal = Terminal::new(TestBackend::new(5, 8)).unwrap(); - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .unwrap(); - - let (workspace_area, _, _) = collapsed_sidebar_sections(area); - assert_eq!( - terminal.backend().buffer()[(workspace_area.x, workspace_area.y)].bg, - app.palette.active_row_bg - ); - - app.selected = 1; - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(workspace_area.x, workspace_area.y)].bg, - app.palette.active_row_bg - ); - assert_eq!( - terminal.backend().buffer()[(workspace_area.x, workspace_area.y + 1)].bg, - app.palette.selection_bg - ); - - app.palette = crate::app::state::Palette::catppuccin(); - app.selected = 0; - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(workspace_area.x, workspace_area.y)].bg, - app.palette.selection_bg - ); - } - - #[test] - fn space_occurrence_style_applies_without_styling_separator() { - let config: crate::config::Config = toml::from_str( - r##" -[ui.sidebar.spaces] -rows = [[{ token = "$hype", fg = "#abcdef", bold = true, dim = false }, "workspace"]] -"##, - ) - .unwrap(); - let mut app = crate::app::state::AppState::test_new(); - app.sidebar_spaces = config.ui.sidebar.spaces; - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.mode = Mode::Terminal; - app.workspaces[0].metadata_tokens.patch( - std::collections::HashMap::from([("hype".into(), Some("HI".into()))]), - None, - std::time::Instant::now(), - ); - - let area = Rect::new(0, 0, 26, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let row = app.view.workspace_card_areas[0].rect.y; - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let h = buffer[(find_symbol_x(buffer, row, 25, "H"), row)].style(); - let i = buffer[(find_symbol_x(buffer, row, 25, "I"), row)].style(); - let separator = buffer[(find_symbol_x(buffer, row, 25, "·"), row)].style(); - - for style in [h, i] { - assert_eq!(style.fg, Some(ratatui::style::Color::Rgb(0xab, 0xcd, 0xef))); - assert!(style.add_modifier.contains(Modifier::BOLD)); - assert!(!style.add_modifier.contains(Modifier::DIM)); - assert_eq!(style.bg, Some(app.palette.active_row_bg)); - } - assert_eq!(separator.fg, Some(app.palette.overlay0)); - assert!(separator.add_modifier.contains(Modifier::DIM)); - assert!(!separator.add_modifier.contains(Modifier::BOLD)); - assert_eq!(separator.bg, Some(app.palette.active_row_bg)); - } - - #[test] - fn occurrence_foreground_flattens_composite_git_status_colors() { - let config: crate::config::Config = toml::from_str( - r##"[ui.sidebar.spaces] -rows = [[{ token = "git_status", fg = "#123456" }]] -"##, - ) - .unwrap(); - let spans = resolved_token_spans( - &[ResolvedToken { - kind: ResolvedTokenKind::GitStatus { - ahead: 2, - behind: 1, - }, - style: config.ui.sidebar.spaces.rows[0][0].parts().1, - }], - ("", Style::default()), - Style::default(), - Style::default(), - Style::default(), - Style::default(), - &crate::app::state::AppState::test_new().palette, - 20, - ); - - assert_eq!( - spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(), - "↑2 ↓1" - ); - assert!(spans - .iter() - .all(|span| { span.style.fg == Some(ratatui::style::Color::Rgb(0x12, 0x34, 0x56)) })); - } - - #[test] - fn default_agent_row_gap_packs_rendering_and_scroll_geometry() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.ensure_test_terminals(); - for (workspace, agent) in app.workspaces.iter().zip([Agent::Pi, Agent::Claude]) { - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(agent); - } - app.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; - assert_eq!(app.sidebar_agents.row_gap, 0); - - let area = Rect::new(0, 0, 20, 5); - let metrics = agent_panel_scroll_metrics(&app, area); - let body = agent_panel_body_rect(area, false); - let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); - terminal - .draw(|frame| render_agent_detail(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - - assert_eq!(metrics.viewport_rows, 2); - assert_eq!(metrics.max_offset_from_bottom, 0); - assert_eq!(row_text(buffer, body.y, body.width), " pi"); - assert_eq!(row_text(buffer, body.y + 1, body.width), " claude"); - } - - #[test] - fn narrow_agent_rows_preserve_later_tab_tokens() { - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = Workspace::test_new("very-long-workspace-name"); - let tab_idx = workspace.test_add_tab(Some("logs")); - let pane_id = workspace.tabs[tab_idx].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[tab_idx].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi); - - let area = Rect::new(0, 0, 18, 20); - let mut terminal = Terminal::new(TestBackend::new(18, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - let body = agent_panel_body_rect(agent_area, false); - let first = row_text(buffer, body.y, 17); - - assert!(first.contains("logs"), "rendered row: {first:?}"); - assert!(first.contains('·'), "rendered row: {first:?}"); - } - - #[test] - fn stripped_terminal_title_renders_with_unicode_width_truncation() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = Workspace::test_new("one"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(Agent::Claude); - terminal.set_terminal_title(Some("⠋ 修复🙂标题很长".into())); - app.sidebar_agents.rows = vec![vec![ - crate::config::AgentSidebarToken::TerminalTitleStripped, - ]]; - - let area = Rect::new(0, 0, 10, 12); - let mut renderer = Terminal::new(TestBackend::new(10, 12)).unwrap(); - renderer - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - let body = agent_panel_body_rect(agent_area, false); - let rendered = row_text(renderer.backend().buffer(), body.y, 9); - - assert!(!rendered.contains('⠋')); - assert!(rendered.contains('修') && rendered.contains('复')); - - let spans = resolved_token_spans( - &[ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle( - "修复🙂标题很长".into(), - ))], - ("", Style::default()), - Style::default(), - Style::default(), - Style::default(), - Style::default(), - &app.palette, - 8, - ); - let text = spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(); - assert!(display_width(&text) <= 8, "resolved title: {text:?}"); - } - - #[test] - fn variable_agent_heights_pack_the_bottom_and_reveal_targets() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![ - Workspace::test_new("one"), - Workspace::test_new("two"), - Workspace::test_new("three"), - ]; - app.ensure_test_terminals(); - for workspace in &app.workspaces { - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi); - } - let first_pane = app.workspaces[0].tabs[0].root_pane; - let first_terminal = app.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.terminals - .get_mut(&first_terminal) - .unwrap() - .metadata_tokens - .patch( - std::collections::HashMap::from([ - ("a".into(), Some("a".into())), - ("b".into(), Some("b".into())), - ]), - None, - std::time::Instant::now(), - ); - app.sidebar_agents.rows = vec![ - vec![crate::config::AgentSidebarToken::Agent], - vec![crate::config::AgentSidebarToken::Custom("a".into())], - vec![crate::config::AgentSidebarToken::Custom("b".into())], - ]; - let area = Rect::new(0, 0, 20, 6); - - let metrics = agent_panel_scroll_metrics(&app, area); - assert_eq!(metrics.max_offset_from_bottom, 1); - assert_eq!(agent_panel_scroll_for_target(&app, area, 0, 2), 1); - } - - #[test] - fn oversized_space_layout_is_clipped_to_the_section_body() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]; 6]; - let area = Rect::new(0, 0, 20, 10); - let workspace_area = workspace_list_rect(area, app.sidebar_section_split); - let body = workspace_list_body_rect(workspace_area, false); - - let metrics = workspace_list_scroll_metrics(&app, workspace_area); - let (cards, _) = compute_workspace_list_areas(&app, area); - - assert_eq!(metrics.viewport_rows, 1); - assert_eq!(cards.len(), 1); - assert_eq!(cards[0].ws_idx, 0); - assert_eq!(cards[0].rect.height, body.height); - } - - #[test] - fn oversized_agent_override_is_clipped_to_the_panel_body() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = Workspace::test_new("one"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - app.sidebar_agents.rows_by_agent.insert( - "claude".into(), - vec![vec![crate::config::AgentSidebarToken::Agent]; 6], - ); - let panel = Rect::new(0, 0, 20, 5); - - let metrics = agent_panel_scroll_metrics(&app, panel); - - assert_eq!(metrics.viewport_rows, 1); - assert_eq!(metrics.max_offset_from_bottom, 0); - let entry = agent_panel_entries(&app).pop().unwrap(); - assert_eq!( - agent_entry_height_in_body(&app, &entry, agent_panel_body_rect(panel, false).height), - agent_panel_body_rect(panel, false).height - ); - } - - #[test] - fn render_sidebar_toggle_draws_expanded_collapse_icon() { - let app = crate::app::state::AppState::test_new(); - let area = Rect::new(0, 0, 26, 20); - let mut terminal = - Terminal::new(TestBackend::new(26, 20)).expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_toggle(&app, frame, area, false, &app.palette)) - .expect("sidebar toggle should render"); - - let toggle = expanded_sidebar_toggle_rect(area); - assert_eq!( - terminal.backend().buffer()[(toggle.x, toggle.y)].symbol(), - "«" - ); - } - - #[test] - fn expanded_sidebar_toggle_sits_inside_sidebar_content() { - let area = Rect::new(0, 0, 26, 20); - let toggle = expanded_sidebar_toggle_rect(area); - - assert_eq!(toggle.x, area.x + area.width - 2); - assert_eq!(toggle.y, area.y + area.height - 1); - } - - #[test] - fn agent_panel_tab_label_visibility_tracks_tab_identity() { - let mut app = crate::app::state::AppState::test_new(); - let single_auto = Workspace::test_new("auto"); - let mut single_custom = Workspace::test_new("custom"); - single_custom.tabs[0].set_custom_name("focus".into()); - let mut multi = Workspace::test_new("multi"); - multi.test_add_tab(Some("logs")); - - app.workspaces = vec![single_auto, single_custom, multi]; - app.ensure_test_terminals(); - for (ws_idx, tab_idx, agent) in [ - (0, 0, Agent::Pi), - (1, 0, Agent::Claude), - (2, 0, Agent::Codex), - (2, 1, Agent::Pi), - ] { - let pane_id = app.workspaces[ws_idx].tabs[tab_idx].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[tab_idx].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(agent); - } - - let entries = agent_panel_entries(&app); - let labels: Vec<_> = entries - .iter() - .map(|entry| { - ( - entry.primary_label.as_str(), - entry.primary_tab_label.as_deref(), - ) - }) - .collect(); - - assert_eq!( - labels, - [ - ("auto", None), - ("custom", Some("focus")), - ("multi", Some("1")), - ("multi", Some("logs")), - ] - ); - } - - #[test] - fn priority_agent_panel_sort_uses_attention_then_space_order() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![ - Workspace::test_new("one"), - Workspace::test_new("two"), - Workspace::test_new("three"), - Workspace::test_new("four"), - ]; - app.ensure_test_terminals(); - app.active = Some(0); - app.selected = 0; - app.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; - - let set_state = |app: &mut crate::app::state::AppState, ws_idx: usize, state| { - let pane = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(Agent::Claude); - terminal.state = state; - }; - set_state(&mut app, 0, AgentState::Working); - set_state(&mut app, 1, AgentState::Idle); - set_state(&mut app, 2, AgentState::Working); - set_state(&mut app, 3, AgentState::Blocked); - - let done_pane = app.workspaces[1].tabs[0].root_pane; - app.workspaces[1].tabs[0] - .panes - .get_mut(&done_pane) - .unwrap() - .seen = false; - - let labels: Vec = agent_panel_entries(&app) - .into_iter() - .map(|entry| entry.primary_label) - .collect(); - - assert_eq!(labels, ["four", "two", "one", "three"]); - } - - #[test] - fn collapsed_sidebar_numbers_grouped_agents_by_list_position() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.ensure_test_terminals(); - - for ws_idx in 0..app.workspaces.len() { - let pane = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - } - - let area = Rect::new(0, 0, 4, 12); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .expect("collapsed sidebar should render"); - - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(detail_area.x, detail_area.y)].symbol(), "1"); - assert_eq!(buffer[(detail_area.x, detail_area.y + 1)].symbol(), "2"); - } - - /// Two agent panes in one workspace plus a second workspace, so the - /// assertions can tell pane-level highlighting apart from workspace-level. - fn collapsed_agent_app() -> (crate::app::state::AppState, PaneId, PaneId) { - let mut app = crate::app::state::AppState::test_new(); - let mut first = Workspace::test_new("one"); - let second_pane = first.test_split(Direction::Horizontal); - let first_pane = first.tabs[0].root_pane; - app.workspaces = vec![first, Workspace::test_new("two")]; - app.ensure_test_terminals(); - - let terminal_ids: Vec<_> = app - .workspaces - .iter() - .flat_map(|ws| ws.tabs.iter()) - .flat_map(|tab| tab.panes.values()) - .map(|pane| pane.attached_terminal_id.clone()) - .collect(); - for terminal_id in terminal_ids { - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - } - - (app, first_pane, second_pane) - } - - fn collapsed_agent_row_styles( - app: &crate::app::state::AppState, - area: Rect, - detail_area: Rect, - rows: u16, - ) -> Vec> { - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - terminal - .draw(|frame| render_sidebar_collapsed(app, frame, area)) - .expect("collapsed sidebar should render"); - let buffer = terminal.backend().buffer(); - (0..rows) - .map(|row| { - (detail_area.x..detail_area.x + detail_area.width) - .map(|x| buffer[(x, detail_area.y + row)].style()) - .collect() - }) - .collect() - } - - #[test] - fn collapsed_sidebar_highlights_only_the_focused_agent_pane() { - let (mut app, first_pane, second_pane) = collapsed_agent_app(); - app.active = Some(0); - app.workspaces[0].tabs[0].layout.focus_pane(second_pane); - assert!(app.is_active_pane(0, 0, second_pane)); - assert!(!app.is_active_pane(0, 0, first_pane)); - - let area = Rect::new(0, 0, 4, 14); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let rows = collapsed_agent_row_styles(&app, area, detail_area, 3); - - let highlighted: Vec<_> = rows - .iter() - .filter(|cells| { - cells - .iter() - .all(|style| style.bg == Some(app.palette.active_row_bg)) - }) - .collect(); - assert_eq!( - highlighted.len(), - 1, - "only the focused agent pane should be highlighted, across the whole row" - ); - assert_eq!(highlighted[0][0].fg, Some(app.palette.text)); - - let muted = rows - .iter() - .filter(|cells| cells[0].fg == Some(app.palette.overlay0)) - .count(); - assert_eq!( - muted, 2, - "the sibling pane in the active workspace and the other workspace stay muted" - ); - } - - #[test] - fn collapsed_sidebar_does_not_highlight_agents_without_active_workspace() { - let (mut app, _, _) = collapsed_agent_app(); - app.active = None; - - let area = Rect::new(0, 0, 4, 14); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let rows = collapsed_agent_row_styles(&app, area, detail_area, 3); - - for cells in rows { - assert_eq!(cells[0].fg, Some(app.palette.overlay0)); - for style in cells { - assert_ne!(style.bg, Some(app.palette.active_row_bg)); - } - } - } - - #[test] - fn collapsed_sidebar_keeps_workspace_status_visible_for_two_digit_positions() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = (1..=10) - .map(|idx| Workspace::test_new(&format!("workspace-{idx}"))) - .collect(); - app.ensure_test_terminals(); - - for ws_idx in 0..app.workspaces.len() { - let pane = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - } - - let area = Rect::new(0, 0, 4, 25); - let (workspace_area, _, _) = collapsed_sidebar_sections(area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .expect("collapsed sidebar should render"); - - let tenth_row = workspace_area.y + 9; - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(workspace_area.x, workspace_area.y)].symbol(), "1"); - assert_eq!( - buffer[(workspace_area.x + 1, workspace_area.y)].symbol(), - " " - ); - assert_eq!( - buffer[(workspace_area.x + 2, workspace_area.y)].symbol(), - "·" - ); - assert_eq!(buffer[(workspace_area.x, tenth_row)].symbol(), "1"); - assert_eq!(buffer[(workspace_area.x + 1, tenth_row)].symbol(), "0"); - assert_eq!(buffer[(workspace_area.x + 2, tenth_row)].symbol(), "·"); - } - - #[test] - fn collapsed_sidebar_keeps_status_visible_for_two_digit_positions() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = (1..=10) - .map(|idx| Workspace::test_new(&format!("workspace-{idx}"))) - .collect(); - app.ensure_test_terminals(); - - for ws_idx in 0..app.workspaces.len() { - let pane = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - } - - let area = Rect::new(0, 0, 4, 25); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .expect("collapsed sidebar should render"); - - let tenth_row = detail_area.y + 9; - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(detail_area.x, tenth_row)].symbol(), "1"); - assert_eq!(buffer[(detail_area.x + 1, tenth_row)].symbol(), "0"); - assert_eq!(buffer[(detail_area.x + 2, tenth_row)].symbol(), "·"); - } - - #[test] - fn collapsed_sidebar_numbers_priority_agents_by_list_position() { - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - let mut second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - let urgent_pane = second.test_split(ratatui::layout::Direction::Horizontal); - - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![first, second]; - app.ensure_test_terminals(); - app.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; - app.status_indicators = crate::config::StatusIndicatorStyle::Symbols; - - let set_state = |app: &mut crate::app::state::AppState, ws_idx: usize, pane_id, state| { - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(Agent::Claude); - terminal.state = state; - }; - set_state(&mut app, 0, first_pane, AgentState::Idle); - set_state(&mut app, 1, second_pane, AgentState::Working); - set_state(&mut app, 1, urgent_pane, AgentState::Blocked); - app.workspaces[0].tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .seen = false; - - assert_eq!(app.workspaces[1].public_pane_number(urgent_pane), Some(2)); - assert_eq!(agent_panel_entries(&app)[0].pane_id, urgent_pane); - - let area = Rect::new(0, 0, 4, 16); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .expect("collapsed sidebar should render"); - - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(detail_area.x, detail_area.y)].symbol(), "1"); - assert_eq!(buffer[(detail_area.x, detail_area.y + 1)].symbol(), "2"); - assert_eq!(buffer[(detail_area.x, detail_area.y + 2)].symbol(), "3"); - assert_eq!(buffer[(detail_area.x + 2, detail_area.y)].symbol(), "×"); - assert_eq!( - buffer[(detail_area.x + 2, detail_area.y)].style().fg, - Some(app.palette.red) - ); - assert_eq!(buffer[(detail_area.x + 2, detail_area.y + 1)].symbol(), "✓"); - assert_eq!( - buffer[(detail_area.x + 2, detail_area.y + 1)].style().fg, - Some(app.palette.teal) - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn all_workspaces_agent_panel_entries_use_live_root_runtime_cwd_for_workspace_label() { - let unique = format!( - "herdr-agent-panel-runtime-cwd-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - let root = std::env::temp_dir().join(unique); - let stale_cwd = root.join("issue-264-nix-support"); - let live_cwd = root.join("herdr"); - std::fs::create_dir_all(stale_cwd.join(".git")).unwrap(); - std::fs::create_dir_all(live_cwd.join(".git")).unwrap(); - - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = Workspace::test_new("stale-name"); - workspace.custom_name = None; - workspace.identity_cwd = stale_cwd.clone(); - let pane = workspace.tabs[0].root_pane; - - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.cwd = stale_cwd; - terminal.detected_agent = Some(Agent::Pi); - app.active = Some(0); - app.selected = 0; - - let (events, _) = tokio::sync::mpsc::channel(4); - let runtime = crate::terminal::TerminalRuntime::spawn( - pane, - 24, - 80, - live_cwd.clone(), - 0, - crate::terminal_theme::TerminalTheme::default(), - None, - crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), - &crate::pane::PaneLaunchEnv::default(), - events, - std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(crate::render_signal::RenderSignal::new()), - ) - .unwrap(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - let mut runtime_registry = TerminalRuntimeRegistry::new(); - runtime_registry.insert(terminal_id, runtime); - let entries = agent_panel_entries_from(&app, &runtime_registry); - let primary_label = entries[0].primary_label.clone(); - - for (_, runtime) in runtime_registry.drain() { - runtime.shutdown(); - } - let _ = std::fs::remove_dir_all(root); - - assert_eq!(primary_label, "herdr"); - } - - #[test] - fn all_workspaces_agent_panel_entries_prefer_agent_names_for_agent_identity() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = Workspace::test_new("bridge"); - let first_pane = workspace.tabs[0].root_pane; - - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let first_terminal_id = app.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - app.terminals - .get_mut(&first_terminal_id) - .unwrap() - .set_agent_name("planner".into()); - app.active = Some(0); - app.selected = 0; - - let entries = agent_panel_entries(&app); - assert_eq!(entries[0].primary_label, "bridge"); - assert_eq!(entries[0].agent_label.as_deref(), Some("planner")); - } - - #[test] - fn expanded_sidebar_sections_handle_tiny_heights() { - let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9); - - assert_eq!(ws_area, Rect::new(0, 0, 19, 3)); - assert_eq!(detail_area, Rect::new(0, 3, 19, 2)); - } - - #[test] - fn sidebar_section_divider_is_hidden_for_tiny_heights() { - let divider = sidebar_section_divider_rect(Rect::new(0, 0, 20, 5), 0.5); - - assert_eq!(divider, Rect::default()); - } - - #[test] - fn grouped_child_label_keeps_custom_workspace_name() { - assert_eq!( - grouped_child_display_label("renamed issue", Some("worktree/issue-137"), true), - "renamed issue" - ); - } - - #[test] - fn grouped_child_label_uses_short_branch_for_auto_named_workspace() { - assert_eq!( - grouped_child_display_label("herdr-issue", Some("worktree/issue-137"), false), - "issue-137" - ); - } - - #[test] - fn workspace_list_truncates_cjk_branch_without_panic() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("repo"); - ws.cached_git_branch = Some("feature/中文-分支-644".into()); - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.view.workspace_card_areas = vec![crate::app::state::WorkspaceCardArea { - ws_idx: 0, - rect: Rect::new(0, 1, 15, 2), - indented: false, - }]; - - let mut terminal = Terminal::new(TestBackend::new(15, 6)).expect("test terminal"); - let runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - - terminal - .draw(|frame| { - render_workspace_list(&app, &runtimes, frame, Rect::new(0, 0, 15, 6), false) - }) - .expect("workspace list should render"); - } - - fn workspace_with_worktree_space( - name: &str, - key: Option<&str>, - checkout_key: &str, - ) -> crate::workspace::Workspace { - let mut ws = crate::workspace::Workspace::test_new(name); - if let Some(key) = key { - ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - checkout_path: std::path::PathBuf::from(checkout_key), - is_linked_worktree: name != "main", - }); - } - ws - } - - fn workspace_with_git_space(name: &str, key: &str) -> crate::workspace::Workspace { - let mut ws = crate::workspace::Workspace::test_new(name); - ws.cached_git_space = Some(crate::workspace::GitSpaceMetadata { - key: key.into(), - checkout_key: format!("/repo/{name}"), - repo_name: "herdr".into(), - repo_root: std::path::PathBuf::from(format!("/repo/{name}")), - is_linked_worktree: false, - }); - ws - } - - #[test] - fn desktop_worktree_tree_aligns_parents_and_marks_children() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), - Workspace::test_new("notes"), - ]; - app.sidebar_spaces.rows = vec![vec![ - crate::config::SpaceSidebarToken::StateIcon, - crate::config::SpaceSidebarToken::Workspace, - ]]; - app.sidebar_spaces.row_gap = 0; - let area = Rect::new(0, 0, 30, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let list_area = workspace_list_rect(area, app.sidebar_section_split); - - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal - .draw(|frame| { - render_workspace_list( - &app, - &TerminalRuntimeRegistry::new(), - frame, - list_area, - false, - ) - }) - .unwrap(); - - let buffer = terminal.backend().buffer(); - let cards = &app.view.workspace_card_areas; - let parent_name_x = find_symbol_x(buffer, cards[0].rect.y, cards[0].rect.width, "m"); - let plain_name_x = find_symbol_x(buffer, cards[3].rect.y, cards[3].rect.width, "n"); - assert_eq!(parent_name_x, plain_name_x); - assert_eq!(buffer[(cards[1].rect.x + 3, cards[1].rect.y)].symbol(), "├"); - assert_eq!(buffer[(cards[2].rect.x + 3, cards[2].rect.y)].symbol(), "└"); - assert_eq!( - buffer[(cards[0].rect.x + cards[0].rect.width - 1, cards[0].rect.y)].symbol(), - "▾" - ); - } - - #[test] - fn desktop_worktree_connector_uses_full_list_at_viewport_boundary() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), - ]; - app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; - app.sidebar_spaces.row_gap = 0; - let area = Rect::new(0, 0, 30, 10); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - assert_eq!(app.view.workspace_card_areas.len(), 2); - let list_area = workspace_list_rect(area, app.sidebar_section_split); - - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal - .draw(|frame| { - render_workspace_list( - &app, - &TerminalRuntimeRegistry::new(), - frame, - list_area, - false, - ) - }) - .unwrap(); - - let child = app.view.workspace_card_areas[1]; - assert_eq!( - terminal.backend().buffer()[(child.rect.x + 3, child.rect.y)].symbol(), - "├" - ); - } - - #[test] - fn parent_workspace_row_stays_clickable_when_grouped() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - app.sidebar_spaces.row_gap = 1; - - let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 20)); - - assert!(headers.is_empty()); - assert_eq!(cards[0].ws_idx, 0); - assert!(!cards[0].indented); - assert_eq!(cards[1].ws_idx, 1); - assert!(cards[1].indented); - assert_eq!(cards[1].rect.y, cards[0].rect.y + cards[0].rect.height); - } - - #[test] - fn space_row_gap_preserves_compact_worktree_children() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), - Workspace::test_new("notes"), - ]; - app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; - app.sidebar_spaces.row_gap = 2; - - let (spacious, _) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 30)); - assert_eq!( - spacious[1].rect.y, - spacious[0].rect.y + spacious[0].rect.height - ); - assert_eq!( - spacious[2].rect.y, - spacious[1].rect.y + spacious[1].rect.height - ); - assert_eq!( - spacious[3].rect.y, - spacious[2].rect.y + spacious[2].rect.height + 2 - ); - let spacious_metrics = workspace_list_scroll_metrics(&app, Rect::new(0, 0, 30, 7)); - assert_eq!(spacious_metrics.viewport_rows, 3); - assert_eq!(spacious_metrics.max_offset_from_bottom, 2); - - app.sidebar_spaces.row_gap = 0; - let (packed, _) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 30)); - assert!(packed - .windows(2) - .all(|pair| pair[1].rect.y == pair[0].rect.y + pair[0].rect.height)); - let packed_metrics = workspace_list_scroll_metrics(&app, Rect::new(0, 0, 30, 7)); - assert_eq!(packed_metrics.viewport_rows, 4); - assert_eq!(packed_metrics.max_offset_from_bottom, 0); - } - - #[test] - fn packed_workspace_drag_indicator_overlays_an_internal_boundary() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - Workspace::test_new("a"), - Workspace::test_new("b"), - Workspace::test_new("c"), - ]; - app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; - app.sidebar_spaces.row_gap = 0; - let area = Rect::new(0, 0, 30, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let list_area = workspace_list_rect(area, app.sidebar_section_split); - let indicator_row = workspace_drop_indicator_row( - &app, - &app.view.workspace_card_areas, - list_area, - crate::app::state::WorkspaceDropTarget::Before(2), - ) - .unwrap(); - assert_eq!(indicator_row, app.view.workspace_card_areas[1].rect.y); - app.drag = Some(crate::app::state::DragState { - target: crate::app::state::DragTarget::WorkspaceReorder { - source_id: 0, - source_ws_idx: 0, - drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(2)), - }, - }); - - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal - .draw(|frame| { - render_workspace_list( - &app, - &TerminalRuntimeRegistry::new(), - frame, - list_area, - false, - ) - }) - .unwrap(); - - assert_eq!( - terminal.backend().buffer()[(list_area.x, indicator_row)].symbol(), - "─" - ); - } - - #[test] - fn linked_only_worktree_members_do_not_form_parentless_group() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), - ]; - - let entries = workspace_list_entries(&app); - - assert_eq!( - entries, - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false - }, - ] - ); - } - - #[test] - fn compact_space_group_scroll_clamps_when_all_entries_fit() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("one", Some("repo-key"), "/repo/herdr-one"), - workspace_with_worktree_space("two", Some("repo-key"), "/repo/herdr-two"), - ]; - let area = Rect::new(0, 0, 30, 20); - app.workspace_scroll = normalized_workspace_scroll(&app, area, 2); - - let (cards, headers) = compute_workspace_list_areas(&app, area); - - assert!(headers.is_empty()); - assert_eq!(app.workspace_scroll, 0); - assert_eq!(cards.len(), 3); - assert_eq!(cards[2].ws_idx, 2); - } - - #[test] - fn workspace_scroll_metrics_count_display_entries_not_raw_workspaces() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - Workspace::test_new("notes"), - ]; - for workspace in &mut app.workspaces { - workspace.cached_git_branch = Some("main".into()); - } - app.collapsed_space_keys.insert("repo-key".into()); - app.active = None; - app.mode = Mode::Terminal; - - let ws_area = Rect::new(0, 0, 30, 6); - let metrics = workspace_list_scroll_metrics(&app, ws_area); - - assert_eq!(metrics.viewport_rows, 1); - assert_eq!(metrics.max_offset_from_bottom, 1); - assert_eq!(metrics.offset_from_bottom, 1); - } - - #[test] - fn workspace_scroll_offset_applies_to_group_children() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - Workspace::test_new("notes"), - ]; - app.collapsed_space_keys.insert("repo-key".into()); - app.active = None; - app.mode = Mode::Terminal; - app.workspace_scroll = 1; - - let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 12)); - - assert!(headers.is_empty()); - assert_eq!(cards.len(), 1); - assert_eq!(cards[0].ws_idx, 2); - } - - #[test] - fn workspace_list_entries_group_multiple_workspaces_in_same_git_space() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: true, - }, - ] - ); - } - - #[test] - fn workspace_list_entries_group_non_contiguous_explicit_members() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_git_space("normal", "other-key"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 2, - indented: true, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false, - }, - ] - ); - } - - #[test] - fn workspace_list_entries_do_not_group_normal_git_workspaces() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_git_space("one", "repo-key"), - workspace_with_git_space("two", "repo-key"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false, - }, - ] - ); - } - - #[test] - fn workspace_list_entries_do_not_auto_attach_normal_git_workspace_to_group() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_git_space("scratch", "repo-key"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 2, - indented: true, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false, - }, - ] - ); - } - - #[test] - fn workspace_list_entries_leave_single_git_and_non_git_workspaces_flat() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_git_space("one", "repo-key"), - workspace_with_worktree_space("notes", None, "/notes"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false, - }, - ] - ); - } - - #[test] - fn collapsed_group_hides_inactive_children_but_keeps_active_visible() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - app.active = Some(1); - app.mode = Mode::Terminal; - app.collapsed_space_keys.insert("repo-key".into()); - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: true, - }, - ] - ); - - app.active = None; - app.mode = Mode::Terminal; - assert_eq!( - workspace_list_entries(&app), - vec![WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }] - ); - } - - #[test] - fn collapsed_group_keeps_selected_child_visible_in_navigate_mode() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - app.mode = Mode::Navigate; - app.selected = 1; - app.active = Some(1); - app.collapsed_space_keys.insert("repo-key".into()); - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: true, - }, - ] - ); - } -} diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs index 903a8a77..eaec154b 100644 --- a/src/ui/sidebar/tokens.rs +++ b/src/ui/sidebar/tokens.rs @@ -163,38 +163,40 @@ pub(crate) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &' mod tests { use super::*; use crate::config::{AgentSidebarToken, SpaceSidebarToken}; - use crate::detect::AgentState; - fn entry() -> super::super::AgentPanelEntry { - super::super::AgentPanelEntry { - ws_idx: 0, - tab_idx: 0, - pane_id: crate::layout::PaneId::from_raw(1), - primary_label: "repo".into(), - primary_tab_label: None, - pane_label: None, + struct Entry { + workspace: String, + tab: Option, + pane: Option, + agent_label: Option, + terminal_title: Option, + terminal_title_stripped: Option, + canonical_agent: Option, + tokens: std::collections::HashMap, + } + + fn entry() -> Entry { + Entry { + workspace: "repo".into(), + tab: None, + pane: None, + agent_label: Some("pi".into()), terminal_title: None, terminal_title_stripped: None, - agent_label: Some("pi".into()), - agent_kind_label: Some("pi".into()), - agent: Some(crate::detect::Agent::Pi), - state: AgentState::Working, - seen: true, - last_agent_state_change_seq: None, - state_labels: std::collections::HashMap::new(), + canonical_agent: Some(crate::detect::Agent::Pi), tokens: std::collections::HashMap::new(), } } - fn context(entry: &super::super::AgentPanelEntry) -> AgentTokenContext<'_> { + fn context(entry: &Entry) -> AgentTokenContext<'_> { AgentTokenContext { - workspace: &entry.primary_label, - tab: entry.primary_tab_label.as_deref(), - pane: entry.pane_label.as_deref(), + workspace: &entry.workspace, + tab: entry.tab.as_deref(), + pane: entry.pane.as_deref(), agent_label: entry.agent_label.as_deref(), terminal_title: entry.terminal_title.as_deref(), terminal_title_stripped: entry.terminal_title_stripped.as_deref(), - canonical_agent: entry.agent, + canonical_agent: entry.canonical_agent, tokens: &entry.tokens, } } @@ -298,7 +300,7 @@ mod tests { ))]] ); - pi.agent = None; + pi.canonical_agent = None; assert_eq!( agent_rows(&config, context(&pi), "working"), vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Workspace( diff --git a/src/ui/status.rs b/src/ui/status.rs index 50d283da..e47e0f90 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -1,18 +1,15 @@ use ratatui::{ buffer::Buffer, - layout::{Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, + layout::Rect, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph, Widget}, - Frame, }; -use super::text::display_width_u16; use super::widgets::panel_contrast_fg; use crate::{ - app::state::{CopyFeedback, Palette, ToastKind, ToastNotification}, - config::{StatusIndicatorStyle, ToastClipboardPosition, ToastHerdrPosition}, - detect::AgentState, + app::state::{CopyFeedback, Palette}, + config::ToastClipboardPosition, }; pub(crate) fn copy_feedback_rect( @@ -21,7 +18,7 @@ pub(crate) fn copy_feedback_rect( offset_rows: u16, position: ToastClipboardPosition, ) -> Rect { - if area.width == 0 || area.height == 0 { + if area.is_empty() { return Rect::default(); } @@ -50,148 +47,54 @@ pub(crate) fn copy_feedback_rect( Rect::new(x, y, width, height) } -pub(crate) fn toast_notification_rect( - area: Rect, - toast: &ToastNotification, - offset_for_warning: bool, - position: ToastHerdrPosition, -) -> Rect { - let content_width = display_width_u16(&toast.title) - .max(display_width_u16(&toast.context)) - .saturating_add(4); - let width = content_width.saturating_add(2).min(area.width); - let content_height = if toast.context.is_empty() { 1 } else { 2 }; - let height = (content_height + 2).min(area.height); - let x = match position { - ToastHerdrPosition::TopLeft | ToastHerdrPosition::BottomLeft => area.x, - ToastHerdrPosition::TopRight | ToastHerdrPosition::BottomRight => { - area.x + area.width.saturating_sub(width) - } - }; - let warning_offset = u16::from(offset_for_warning); - let y = match position { - ToastHerdrPosition::TopLeft | ToastHerdrPosition::TopRight => { - area.y + warning_offset.min(area.height) - } - ToastHerdrPosition::BottomLeft | ToastHerdrPosition::BottomRight => { - area.y + area.height.saturating_sub(height + warning_offset) - } - }; - Rect::new(x, y, width, height) -} - -pub(super) fn render_toast_notification( - frame: &mut Frame, - area: Rect, - toast: &ToastNotification, - offset_for_warning: bool, - position: ToastHerdrPosition, - p: &Palette, -) { - let dot_color = match toast.kind { - ToastKind::NeedsAttention => p.red, - ToastKind::Finished => p.blue, - ToastKind::UpdateInstalled => p.accent, - }; - let toast_area = toast_notification_rect(area, toast, offset_for_warning, position); - - frame.render_widget(Clear, toast_area); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(p.overlay0)) - .style(Style::default().bg(p.panel_bg)); - let inner = block.inner(toast_area); - frame.render_widget(block, toast_area); - - if inner.height < 1 { - return; - } - - let [title_row, context_row] = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner); - - let title = Line::from(vec![ - Span::styled("●", Style::default().fg(dot_color)), - Span::raw(" "), - Span::styled( - &toast.title, - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - ), - ]); - let context = Line::from(vec![ - Span::styled(" ", Style::default().fg(p.overlay0)), - Span::styled(&toast.context, Style::default().fg(p.overlay0)), - ]); - - frame.render_widget(Paragraph::new(title), title_row); - if !toast.context.is_empty() && inner.height >= 2 { - frame.render_widget(Paragraph::new(context), context_row); - } -} - -pub(super) fn render_copy_feedback( - frame: &mut Frame, - area: Rect, - feedback: &CopyFeedback, - offset_rows: u16, - position: ToastClipboardPosition, - p: &Palette, -) { - render_copy_feedback_buffer(frame.buffer_mut(), area, feedback, offset_rows, position, p); -} - pub(crate) fn render_copy_feedback_buffer( buffer: &mut Buffer, area: Rect, feedback: &CopyFeedback, offset_rows: u16, position: ToastClipboardPosition, - p: &Palette, + palette: &Palette, ) { let feedback_area = copy_feedback_rect(area, feedback, offset_rows, position); if feedback_area.is_empty() { return; } - ratatui::widgets::Widget::render(Clear, feedback_area, buffer); + Clear.render(feedback_area, buffer); let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(p.green)) - .style(Style::default().bg(p.panel_bg)); + .border_style(Style::default().fg(palette.green)) + .style(Style::default().bg(palette.panel_bg)); let inner = block.inner(feedback_area); - ratatui::widgets::Widget::render(block, feedback_area, buffer); + block.render(feedback_area, buffer); if inner.height == 0 { return; } let text = Line::from(vec![ - Span::styled("●", Style::default().fg(p.green).bg(p.panel_bg)), + Span::styled("●", Style::default().fg(palette.green).bg(palette.panel_bg)), Span::raw(" "), Span::styled( &feedback.message, Style::default() - .fg(p.text) - .bg(p.panel_bg) + .fg(palette.text) + .bg(palette.panel_bg) .add_modifier(Modifier::BOLD), ), ]); - ratatui::widgets::Widget::render(Paragraph::new(text), inner, buffer); -} - -pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) { - render_config_diagnostic_buffer(frame.buffer_mut(), area, message, p); + Paragraph::new(text).render(inner, buffer); } pub(crate) fn render_config_diagnostic_buffer( buffer: &mut Buffer, area: Rect, message: &str, - p: &Palette, + palette: &Palette, ) -> u16 { let style = Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.yellow) + .fg(panel_contrast_fg(palette)) + .bg(palette.yellow) .add_modifier(Modifier::BOLD); let mut rendered_rows = 0u16; @@ -218,164 +121,26 @@ pub(crate) fn render_config_diagnostic_buffer( rendered_rows } -pub(super) fn state_icon_symbol( - state: AgentState, - seen: bool, - indicator_style: StatusIndicatorStyle, -) -> &'static str { - match (indicator_style, state, seen) { - (StatusIndicatorStyle::Dots, AgentState::Blocked, _) => "●", - (StatusIndicatorStyle::Dots, AgentState::Working, _) => "●", - (StatusIndicatorStyle::Dots, AgentState::Idle, false) => "●", - (StatusIndicatorStyle::Dots, AgentState::Idle, true) => "○", - (StatusIndicatorStyle::Dots, AgentState::Unknown, _) => "·", - (StatusIndicatorStyle::Symbols, AgentState::Blocked, _) => "×", - (StatusIndicatorStyle::Symbols, AgentState::Working, _) => "◐", - (StatusIndicatorStyle::Symbols, AgentState::Idle, false) => "✓", - (StatusIndicatorStyle::Symbols, AgentState::Idle, true) => "○", - (StatusIndicatorStyle::Symbols, AgentState::Unknown, _) => "·", - } -} - -pub(super) fn state_icon( - state: AgentState, - seen: bool, - indicator_style: StatusIndicatorStyle, - p: &Palette, -) -> (&'static str, Style) { - ( - state_icon_symbol(state, seen, indicator_style), - Style::default().fg(state_label_color(state, seen, p)), - ) -} - -pub(super) fn state_label(state: AgentState, seen: bool) -> &'static str { - match (state, seen) { - (AgentState::Blocked, _) => "blocked", - (AgentState::Working, _) => "working", - (AgentState::Idle, false) => "done", - (AgentState::Idle, true) => "idle", - (AgentState::Unknown, _) => "idle", - } -} - -pub(super) fn state_label_color(state: AgentState, seen: bool, p: &Palette) -> Color { - match (state, seen) { - (AgentState::Blocked, _) => p.red, - (AgentState::Working, _) => p.yellow, - (AgentState::Idle, false) => p.teal, - (AgentState::Idle, true) => p.green, - (AgentState::Unknown, _) => p.overlay0, - } -} - #[cfg(test)] mod tests { use super::*; - use crate::config::{ToastClipboardPosition, ToastHerdrPosition}; - - fn toast() -> ToastNotification { - ToastNotification { - kind: ToastKind::Finished, - title: "done".to_string(), - context: "workspace".to_string(), - position: None, - target: None, - } - } - - fn feedback() -> CopyFeedback { - CopyFeedback { - message: "copied to clipboard".to_string(), - } - } - - #[test] - fn state_icons_support_dot_and_distinct_symbol_styles() { - let palette = Palette::catppuccin(); - for (indicator_style, expected_symbols) in [ - (StatusIndicatorStyle::Dots, ["●", "●", "●", "○", "·"]), - (StatusIndicatorStyle::Symbols, ["×", "◐", "✓", "○", "·"]), - ] { - for ((state, seen, color), expected_symbol) in [ - (AgentState::Blocked, true, palette.red), - (AgentState::Working, true, palette.yellow), - (AgentState::Idle, false, palette.teal), - (AgentState::Idle, true, palette.green), - (AgentState::Unknown, true, palette.overlay0), - ] - .into_iter() - .zip(expected_symbols) - { - let (actual_symbol, style) = state_icon(state, seen, indicator_style, &palette); - assert_eq!(actual_symbol, expected_symbol); - assert_eq!(display_width_u16(actual_symbol), 1); - assert_eq!(style.fg, Some(color)); - } - } - } - - #[test] - fn toast_rect_uses_configured_corner() { - let area = Rect::new(10, 20, 100, 40); - let toast = toast(); - - let top_left = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopLeft); - assert_eq!(top_left.x, area.x); - assert_eq!(top_left.y, area.y); - - let top_right = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopRight); - assert_eq!(top_right.x + top_right.width, area.x + area.width); - assert_eq!(top_right.y, area.y); - - let bottom_left = - toast_notification_rect(area, &toast, false, ToastHerdrPosition::BottomLeft); - assert_eq!(bottom_left.x, area.x); - assert_eq!(bottom_left.y + bottom_left.height, area.y + area.height); - - let bottom_right = - toast_notification_rect(area, &toast, false, ToastHerdrPosition::BottomRight); - assert_eq!(bottom_right.x + bottom_right.width, area.x + area.width); - assert_eq!(bottom_right.y + bottom_right.height, area.y + area.height); - } - - #[test] - fn toast_rect_uses_display_width_for_cjk_labels() { - let area = Rect::new(0, 0, 100, 20); - let toast = ToastNotification { - kind: ToastKind::NeedsAttention, - title: "重构用户认证模块".to_string(), - context: "提交 herdr 的反馈".to_string(), - position: None, - target: None, - }; - - let rect = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopRight); - - let expected_content_width = - display_width_u16(&toast.title).max(display_width_u16(&toast.context)) + 6; - assert_eq!(rect.width, expected_content_width); - assert_eq!(rect.x + rect.width, area.x + area.width); - } #[test] fn copy_feedback_rect_uses_configured_position() { let area = Rect::new(10, 20, 100, 40); - let feedback = feedback(); + let feedback = CopyFeedback { + message: "copied to clipboard".to_owned(), + }; - let top_center = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::TopCenter); - assert_eq!(top_center.y, area.y); - assert_eq!( - top_center.x, - area.x + area.width.saturating_sub(top_center.width) / 2 - ); + let top = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::TopCenter); + assert_eq!(top.y, area.y); + assert_eq!(top.x, area.x + area.width.saturating_sub(top.width) / 2); - let bottom_center = - copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::BottomCenter); - assert_eq!(bottom_center.y + bottom_center.height, area.y + area.height); + let bottom = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::BottomCenter); + assert_eq!(bottom.bottom(), area.bottom()); assert_eq!( - bottom_center.x, - area.x + area.width.saturating_sub(bottom_center.width) / 2 + bottom.x, + area.x + area.width.saturating_sub(bottom.width) / 2 ); } } diff --git a/src/ui/tab_surface.rs b/src/ui/tab_surface.rs index 99ca9727..766c1d83 100644 --- a/src/ui/tab_surface.rs +++ b/src/ui/tab_surface.rs @@ -1,8 +1,7 @@ use ratatui::{layout::Rect, Frame}; use super::panes::{compute_pane_infos, render_panes, resize_tab_panes}; -use crate::app::state::ViewState; -use crate::app::{AppState, Mode}; +use crate::app::AppState; use crate::layout::{PaneInfo, SplitBorder}; use crate::protocol::CursorState; use crate::terminal::TerminalRuntimeRegistry; @@ -18,15 +17,6 @@ pub(crate) struct TabSurfaceView<'a> { pub(crate) split_borders: &'a [SplitBorder], } -impl ViewState { - pub(crate) fn tab_surface(&self) -> TabSurfaceView<'_> { - TabSurfaceView { - pane_infos: &self.pane_infos, - split_borders: &self.split_borders, - } - } -} - pub(crate) fn compute_tab_surface( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -105,10 +95,6 @@ pub(crate) fn tab_surface_cursor( terminal_runtimes: &TerminalRuntimeRegistry, surface: TabSurfaceView<'_>, ) -> Option { - if app.mode != Mode::Terminal { - return None; - } - let ws_idx = app.active?; let info = surface.pane_infos.iter().find(|info| info.is_focused)?; if !app.pane_exposes_host_cursor(ws_idx, info.id) { @@ -189,12 +175,9 @@ mod tests { app.workspaces = vec![workspace]; app.active = Some(0); app.selected = 0; - app.mode = Mode::Terminal; let full_area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app, full_area); - let area = app.view.terminal_area; - assert_eq!(area, Rect::new(26, 1, 80, 19)); + let area = full_area; let surface = compute_tab_surface( &app, &TerminalRuntimeRegistry::new(), @@ -207,7 +190,6 @@ mod tests { app.view.terminal_area = Rect::new(9, 8, 7, 6); app.view.pane_infos.clear(); - app.view.split_borders.clear(); let surface_view = TabSurfaceView { pane_infos: &surface.pane_infos, @@ -238,90 +220,4 @@ mod tests { .any(|(_, symbol, link)| { symbol == "L" && link == uri })); assert!(tab_surface_cursor(&app, &TerminalRuntimeRegistry::new(), surface_view,).is_some()); } - - fn full_app_frame(app: &mut AppState, area: Rect) -> crate::protocol::FrameData { - let (buffer, cursor) = crate::server::render_stream::render_virtual(app, area, true); - let hyperlinks = - crate::server::render_stream::visible_hyperlinks(app, &TerminalRuntimeRegistry::new()); - crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks( - &buffer, - cursor, - &hyperlinks, - ) - } - - fn frame_digest(frame: &crate::protocol::FrameData) -> String { - use sha2::{Digest, Sha256}; - - let encoded = bincode::serde::encode_to_vec(frame, bincode::config::standard()).unwrap(); - format!("{:x}", Sha256::digest(encoded)) - } - - fn full_app_characterization_state(uri: &str) -> AppState { - let mut workspace = Workspace::test_new("characterization"); - workspace.identity_cwd = std::path::PathBuf::from("characterization"); - workspace.cached_git_branch = None; - workspace.cached_git_ahead_behind = None; - workspace.cached_git_space = None; - workspace.test_add_tab(Some("logs")); - workspace.switch_tab(0); - let left = workspace.tabs[0].root_pane; - let right = workspace.test_split(Direction::Horizontal); - workspace.insert_test_runtime( - left, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - 40, - 10, - format!("\x1b]8;;{uri}\x1b\\LINK\x1b]8;;\x1b\\").as_bytes(), - ), - ); - workspace.insert_test_runtime( - right, - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 10, b"RIGHT\r\nPANE"), - ); - - let mut app = AppState::test_new(); - app.workspaces = vec![workspace]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app - } - - #[tokio::test] - async fn desktop_full_app_semantic_frame_is_characterized() { - let uri = "https://example.com/full-app"; - let mut app = full_app_characterization_state(uri); - let frame = full_app_frame(&mut app, Rect::new(0, 0, 106, 20)); - - assert_eq!((frame.width, frame.height), (106, 20)); - assert_eq!(app.view.sidebar_rect, Rect::new(0, 0, 26, 20)); - assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 80, 1)); - assert_eq!(app.view.terminal_area, Rect::new(26, 1, 80, 19)); - assert_eq!(app.view.pane_infos.len(), 2); - assert!(!app.view.split_borders.is_empty()); - assert!(frame.cursor.is_some()); - assert_eq!(frame.hyperlinks, vec![uri.to_owned()]); - assert_eq!( - frame_digest(&frame), - "a7c21fa42305a41231c7ae254f264f6ef923f46301d8fc4cd35ab6dfdd651b6b" - ); - } - - #[tokio::test] - async fn mobile_full_app_semantic_frame_is_characterized() { - let mut app = full_app_characterization_state("https://example.com/mobile"); - app.mode = Mode::Navigate; - let frame = full_app_frame(&mut app, Rect::new(0, 0, 44, 20)); - - assert_eq!((frame.width, frame.height), (44, 20)); - assert_eq!(app.view.layout, crate::app::state::ViewLayout::Mobile); - assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 44, 2)); - assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18)); - assert_eq!(frame.cursor, None); - assert_eq!( - frame_digest(&frame), - "295608a66067f1e1f066c0adb3cf427e8a2d68bba8f68949fb72d464dcd8baab" - ); - } } diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs deleted file mode 100644 index 603422fb..00000000 --- a/src/ui/tabs.rs +++ /dev/null @@ -1,788 +0,0 @@ -use ratatui::{ - layout::Rect, - style::{Modifier, Style}, - widgets::Paragraph, - Frame, -}; - -use super::text::display_width_u16; -use super::widgets::panel_contrast_fg; -use crate::app::AppState; - -const MIN_TAB_WIDTH: u16 = 8; -const NEW_TAB_WIDTH: u16 = 3; -const TAB_SCROLL_BUTTON_WIDTH: u16 = 3; -const ZOOM_INDICATOR: &str = "ZOOM"; -// The narrowest overflowing tab strip worth keeping interactive: one -// minimum-width tab, both scroll controls, and the new-tab control. -const MIN_TAB_STRIP_WIDTH: u16 = - MIN_TAB_WIDTH + NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2); - -#[derive(Debug, Clone, Default)] -pub(crate) struct TabBarView { - pub scroll: usize, - pub tab_hit_areas: Vec, - pub scroll_left_hit_area: Rect, - pub scroll_right_hit_area: Rect, - pub new_tab_hit_area: Rect, -} - -fn tab_width(ws: &crate::workspace::Workspace, tab_idx: usize) -> u16 { - display_width_u16(&tab_chrome_label(ws, tab_idx)) - .saturating_add(4) - .max(MIN_TAB_WIDTH) -} - -fn tab_chrome_label(ws: &crate::workspace::Workspace, tab_idx: usize) -> String { - let name = ws - .tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()); - if ws.tabs.get(tab_idx).is_some_and(|tab| tab.zoomed) { - format!("{name} Z") - } else { - name - } -} - -#[derive(Clone, Copy)] -struct VisibleStatusSegment<'a> { - text: &'a str, - accent: bool, -} - -fn visible_status_segments(app: &AppState) -> Vec> { - let zoomed = app - .active - .and_then(|index| app.workspaces.get(index)) - .is_some_and(|workspace| workspace.zoomed); - app.tab_bar_right - .iter() - .filter_map(|segment| match segment { - crate::app::state::TabBarStatusSegment::Zoom if zoomed => Some(VisibleStatusSegment { - text: ZOOM_INDICATOR, - accent: true, - }), - crate::app::state::TabBarStatusSegment::Text(Some(text)) - if display_width_u16(text) > 0 => - { - Some(VisibleStatusSegment { - text, - accent: false, - }) - } - crate::app::state::TabBarStatusSegment::Zoom - | crate::app::state::TabBarStatusSegment::Text(_) => None, - }) - .collect() -} - -fn tab_bar_status_width(app: &AppState) -> u16 { - let segments = visible_status_segments(app); - let content_width = segments.iter().fold(0_u16, |width, segment| { - width.saturating_add(display_width_u16(segment.text)) - }); - let separators = u16::try_from(segments.len().saturating_sub(1)).unwrap_or(u16::MAX); - content_width - .saturating_add(display_width_u16(&app.tab_bar_right_separator).saturating_mul(separators)) -} - -fn tab_bar_status_area(app: &AppState, area: Rect) -> Option { - let width = tab_bar_status_width(app); - if width == 0 { - return None; - } - let reserved = width.saturating_add(1); - (area.width.saturating_sub(reserved) >= MIN_TAB_STRIP_WIDTH) - .then(|| Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1)) -} - -// Tabs win over status decoration on narrow rows. The extra reserved cell is -// the gap between the interactive strip and the right-aligned status entries. -pub(crate) fn tab_bar_content_area(app: &AppState, area: Rect) -> Rect { - let reserved = tab_bar_status_area(app, area) - .map(|status| status.width.saturating_add(1)) - .unwrap_or(0); - Rect { - width: area.width.saturating_sub(reserved), - ..area - } -} - -fn layout_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect, scroll: usize) -> Vec { - let mut rects = vec![Rect::default(); ws.tabs.len()]; - if area.width == 0 || area.height == 0 { - return rects; - } - - let mut x = area.x; - let right = area.x + area.width; - for (idx, rect) in rects.iter_mut().enumerate().skip(scroll) { - if x >= right { - break; - } - let desired = tab_width(ws, idx); - let remaining = right.saturating_sub(x); - let width = desired.min(remaining).max(1); - *rect = Rect::new(x, area.y, width, 1); - x = x.saturating_add(width + 1); - } - rects -} - -fn centered_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize { - let mut best_scroll = ws.active_tab; - let mut best_distance = u16::MAX; - let viewport_center = area.x.saturating_mul(2).saturating_add(area.width); - - for scroll in 0..=ws.active_tab { - let rects = layout_tab_hit_areas(ws, area, scroll); - let Some(active_rect) = rects.get(ws.active_tab).copied() else { - continue; - }; - if active_rect.width == 0 { - continue; - } - - let active_center = active_rect - .x - .saturating_mul(2) - .saturating_add(active_rect.width); - let distance = active_center.abs_diff(viewport_center); - if distance <= best_distance { - best_distance = distance; - best_scroll = scroll; - } - } - - best_scroll -} - -fn trailing_tab_controls_x(tab_hit_areas: &[Rect], fallback_x: u16) -> u16 { - tab_hit_areas - .iter() - .rev() - .find(|rect| rect.width > 0) - .map(|rect| rect.x + rect.width) - .unwrap_or(fallback_x) -} - -fn max_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize { - (0..ws.tabs.len()) - .find(|&scroll| { - layout_tab_hit_areas(ws, area, scroll) - .last() - .is_some_and(|rect| rect.width > 0) - }) - .unwrap_or(0) -} - -pub(crate) fn compute_tab_bar_view( - ws: &crate::workspace::Workspace, - area: Rect, - current_scroll: usize, - follow_active: bool, - mouse_chrome: bool, -) -> TabBarView { - if area.width == 0 || area.height == 0 { - return TabBarView::default(); - } - - if !mouse_chrome { - let max_scroll = max_tab_scroll(ws, area); - let scroll = if follow_active { - centered_tab_scroll(ws, area).min(max_scroll) - } else { - current_scroll.min(max_scroll) - }; - return TabBarView { - scroll, - tab_hit_areas: layout_tab_hit_areas(ws, area, scroll), - scroll_left_hit_area: Rect::default(), - scroll_right_hit_area: Rect::default(), - new_tab_hit_area: Rect::default(), - }; - } - - let area_right = area.x + area.width; - let all_tabs_area = Rect::new( - area.x, - area.y, - area.width.saturating_sub(NEW_TAB_WIDTH), - area.height, - ); - let all_tabs = layout_tab_hit_areas(ws, all_tabs_area, 0); - let overflow = all_tabs.iter().any(|rect| rect.width == 0); - if !overflow { - let new_tab_x = trailing_tab_controls_x(&all_tabs, area.x); - let new_tab_hit_area = Rect::new( - new_tab_x, - area.y, - area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH), - 1, - ); - return TabBarView { - scroll: 0, - tab_hit_areas: all_tabs, - scroll_left_hit_area: Rect::default(), - scroll_right_hit_area: Rect::default(), - new_tab_hit_area, - }; - } - - let left_hit_area = Rect::new(area.x, area.y, TAB_SCROLL_BUTTON_WIDTH.min(area.width), 1); - let tab_area_x = left_hit_area.x + left_hit_area.width; - let reserved_trailing_width = NEW_TAB_WIDTH.saturating_add(TAB_SCROLL_BUTTON_WIDTH); - let tab_area_right = area_right.saturating_sub(reserved_trailing_width); - let tab_area = Rect::new( - tab_area_x, - area.y, - tab_area_right.saturating_sub(tab_area_x), - area.height, - ); - - let max_scroll = max_tab_scroll(ws, tab_area); - let scroll = if follow_active { - centered_tab_scroll(ws, tab_area).min(max_scroll) - } else { - current_scroll.min(max_scroll) - }; - let tab_hit_areas = layout_tab_hit_areas(ws, tab_area, scroll); - let trailing_x = trailing_tab_controls_x(&tab_hit_areas, tab_area_x).min(tab_area_right); - let right_hit_area = Rect::new( - trailing_x, - area.y, - area_right - .saturating_sub(trailing_x) - .min(TAB_SCROLL_BUTTON_WIDTH), - 1, - ); - let new_tab_x = right_hit_area.x + right_hit_area.width; - let new_tab_hit_area = Rect::new( - new_tab_x, - area.y, - area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH), - 1, - ); - - TabBarView { - scroll, - tab_hit_areas, - scroll_left_hit_area: left_hit_area, - scroll_right_hit_area: right_hit_area, - new_tab_hit_area, - } -} - -fn tab_drop_indicator_x( - app: &AppState, - ws: &crate::workspace::Workspace, - insert_idx: usize, -) -> Option { - let mut visible_tabs = app - .view - .tab_hit_areas - .iter() - .enumerate() - .filter(|(_, rect)| rect.width > 0); - let first_visible = visible_tabs.clone().next()?; - let last_visible = visible_tabs.next_back().unwrap_or(first_visible); - - if insert_idx == 0 { - return Some(if first_visible.0 == 0 { - first_visible.1.x - } else { - app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width - }); - } - - if let Some((_, rect)) = app - .view - .tab_hit_areas - .iter() - .enumerate() - .find(|(idx, rect)| *idx == insert_idx && rect.width > 0) - { - return Some(rect.x.saturating_sub(1)); - } - - if insert_idx >= ws.tabs.len() { - return Some(if last_visible.0 + 1 >= ws.tabs.len() { - last_visible.1.x + last_visible.1.width - } else { - app.view.tab_scroll_right_hit_area.x.saturating_sub(1) - }); - } - - None -} - -pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { - if area.width == 0 || area.height == 0 { - return; - } - let Some(active_ws_idx) = app.active else { - return; - }; - let Some(ws) = app.workspaces.get(active_ws_idx) else { - return; - }; - let p = &app.palette; - - frame.render_widget( - Paragraph::new(" ".repeat(area.width as usize)).style(Style::default().bg(p.panel_bg)), - area, - ); - - let first_visible_idx = app - .view - .tab_hit_areas - .iter() - .enumerate() - .find(|(_, rect)| rect.width > 0) - .map(|(idx, _)| idx); - let last_visible_idx = app - .view - .tab_hit_areas - .iter() - .enumerate() - .rev() - .find(|(_, rect)| rect.width > 0) - .map(|(idx, _)| idx); - let can_scroll_left = app.view.tab_scroll_left_hit_area.width > 0 && app.tab_scroll > 0; - let can_scroll_right = app.view.tab_scroll_right_hit_area.width > 0 - && last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()); - - if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 { - let style = if can_scroll_left { - Style::default().fg(p.overlay1).bg(p.surface0) - } else { - Style::default() - .fg(p.overlay0) - .bg(p.surface0) - .add_modifier(Modifier::DIM) - }; - frame.render_widget( - Paragraph::new(" < ").style(style), - app.view.tab_scroll_left_hit_area, - ); - } - - if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 { - let style = if can_scroll_right { - Style::default().fg(p.overlay1).bg(p.surface0) - } else { - Style::default() - .fg(p.overlay0) - .bg(p.surface0) - .add_modifier(Modifier::DIM) - }; - frame.render_widget( - Paragraph::new(" > ").style(style), - app.view.tab_scroll_right_hit_area, - ); - } - - for (idx, tab) in ws.tabs.iter().enumerate() { - let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else { - break; - }; - if rect.width == 0 { - continue; - } - let active = idx == ws.active_tab; - let style = if active { - let base = Style::default().fg(panel_contrast_fg(p)).bg(p.accent); - if tab.is_auto_named() { - base - } else { - base.add_modifier(Modifier::BOLD) - } - } else if tab.is_auto_named() { - Style::default() - .fg(p.overlay0) - .bg(p.surface0) - .add_modifier(Modifier::DIM) - } else { - Style::default().fg(p.overlay1).bg(p.surface0) - }; - let width = rect.width as usize; - let name = tab_chrome_label(ws, idx); - // Pad by terminal columns, not chars, so wide glyphs stay centered. - let padding = width.saturating_sub(display_width_u16(&name) as usize); - let left = padding / 2; - let text = format!( - "{empty:left$}{name}{empty:right$}", - empty = "", - right = padding - left - ); - frame.render_widget(Paragraph::new(text).style(style), rect); - } - - if let Some(crate::app::state::DragState { - target: - crate::app::state::DragTarget::TabReorder { - ws_idx, - insert_idx: Some(insert_idx), - .. - }, - }) = &app.drag - { - if *ws_idx == active_ws_idx { - if let Some(x) = tab_drop_indicator_x(app, ws, *insert_idx) { - frame.buffer_mut()[(x.min(area.x + area.width.saturating_sub(1)), area.y)] - .set_symbol("│") - .set_style(Style::default().fg(p.accent)); - } - } - } - - if app.mouse_capture && app.view.new_tab_hit_area.width > 0 { - frame.render_widget( - Paragraph::new(" + ").style(Style::default().fg(p.overlay1)), - app.view.new_tab_hit_area, - ); - } - - if first_visible_idx.is_some_and(|idx| idx > 0) { - let x = if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 { - app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width - } else { - area.x - }; - if x < area.x + area.width { - frame.buffer_mut()[(x, area.y)] - .set_symbol("…") - .set_style(Style::default().fg(p.overlay0)); - } - } - if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) { - let content = tab_bar_content_area(app, area); - let content_right = content.x + content.width; - let x = if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 { - app.view.tab_scroll_right_hit_area.x.saturating_sub(1) - } else { - content_right.saturating_sub(1) - }; - if x >= area.x && x < area.x + area.width { - frame.buffer_mut()[(x, area.y)] - .set_symbol("…") - .set_style(Style::default().fg(p.overlay0)); - } - } - - if let Some(status_area) = tab_bar_status_area(app, area) { - let segments = visible_status_segments(app); - let separator_width = display_width_u16(&app.tab_bar_right_separator); - let mut x = status_area.x; - for (index, segment) in segments.iter().enumerate() { - if index > 0 && separator_width > 0 { - let rect = Rect::new(x, area.y, separator_width, 1); - frame.render_widget( - Paragraph::new(app.tab_bar_right_separator.as_str()) - .style(Style::default().fg(p.overlay0).bg(p.panel_bg)), - rect, - ); - x = x.saturating_add(separator_width); - } - - let width = display_width_u16(segment.text); - let rect = Rect::new(x, area.y, width, 1); - let style = if segment.accent { - Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.accent) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.overlay1).bg(p.panel_bg) - }; - frame.render_widget(Paragraph::new(segment.text).style(style), rect); - x = x.saturating_add(width); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::state::AppState; - use crate::workspace::Workspace; - use ratatui::{backend::TestBackend, Terminal}; - - fn buffer_row_text(buffer: &ratatui::buffer::Buffer, area: Rect, row: u16) -> String { - (area.x..area.x + area.width) - .map(|x| buffer[(x, row)].symbol()) - .collect::() - .trim_end() - .to_string() - } - - #[test] - fn tab_bar_marks_zoomed_tabs_without_renaming_them() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].zoomed = true; - let custom_tab = ws.test_add_tab(Some("test")); - ws.tabs[custom_tab].zoomed = true; - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0); - assert!(row.contains(" 1 Z"), "tab row: {row:?}"); - assert!(row.contains(" test Z"), "tab row: {row:?}"); - assert_eq!(app.workspaces[0].tab_display_name(0).as_deref(), Some("1")); - assert_eq!( - app.workspaces[0].tab_display_name(custom_tab).as_deref(), - Some("test") - ); - } - - #[test] - fn tab_bar_renders_ordered_status_entries_with_separator() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].zoomed = true; - app.tab_bar_right = vec![ - crate::app::state::TabBarStatusSegment::Zoom, - crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())), - crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())), - ]; - app.tab_bar_right_separator = " · ".into(); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 60, 1); - let content = tab_bar_content_area(&app, app.view.tab_bar_rect); - let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas.clone(); - - let backend = TestBackend::new(60, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let buffer = terminal.backend().buffer(); - let row = buffer_row_text(buffer, app.view.tab_bar_rect, 0); - assert!( - row.ends_with("ZOOM · wintermute · 14:30"), - "tab row: {row:?}" - ); - let status_x = 60 - display_width_u16("ZOOM · wintermute · 14:30"); - assert_eq!(buffer[(status_x, 0)].style().bg, Some(app.palette.accent)); - for rect in &view.tab_hit_areas { - assert!(rect.x + rect.width <= content.x + content.width); - } - } - - #[test] - fn hidden_status_entries_do_not_leave_dangling_separators() { - let mut app = AppState::test_new(); - app.tab_bar_right = vec![ - crate::app::state::TabBarStatusSegment::Zoom, - crate::app::state::TabBarStatusSegment::Text(None), - crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())), - ]; - app.tab_bar_right_separator = " | ".into(); - app.workspaces = vec![Workspace::test_new("test")]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 40, 1); - let content = tab_bar_content_area(&app, app.view.tab_bar_rect); - let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(40, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0); - assert!(row.ends_with("wintermute"), "tab row: {row:?}"); - assert!(!row.contains(" | "), "tab row: {row:?}"); - } - - #[test] - fn status_reservation_keeps_a_minimum_width_tab_between_scroll_controls() { - let mut app = AppState::test_new(); - app.tab_bar_right = vec![crate::app::state::TabBarStatusSegment::Text(Some( - "x".into(), - ))]; - let mut workspace = Workspace::test_new("test"); - workspace.test_add_tab(None); - workspace.test_add_tab(None); - app.workspaces = vec![workspace]; - app.active = Some(0); - - let too_narrow = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 1, 1); - assert_eq!(tab_bar_content_area(&app, too_narrow), too_narrow); - - let wide_enough = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 2, 1); - let content = tab_bar_content_area(&app, wide_enough); - assert_eq!(content.width, MIN_TAB_STRIP_WIDTH); - let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, true); - assert!(view.tab_hit_areas[0].width >= MIN_TAB_WIDTH); - } - - #[test] - fn combined_status_entries_yield_to_tab_controls_on_narrow_rows() { - let mut app = AppState::test_new(); - app.tab_bar_right = vec![ - crate::app::state::TabBarStatusSegment::Text(Some( - "a-hostname-wider-than-the-whole-bar".into(), - )), - crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())), - ]; - app.workspaces = vec![Workspace::test_new("test")]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - - assert_eq!( - tab_bar_content_area(&app, app.view.tab_bar_rect), - app.view.tab_bar_rect - ); - assert_eq!(tab_bar_status_area(&app, app.view.tab_bar_rect), None); - - let view = compute_tab_bar_view( - &app.workspaces[0], - tab_bar_content_area(&app, app.view.tab_bar_rect), - 0, - true, - true, - ); - assert!(view.tab_hit_areas[0].width > 0); - assert!(view.new_tab_hit_area.width > 0); - } - - #[test] - fn cjk_tab_labels_are_centered_by_display_width() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("提交 herdr 的反馈".into()); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - // 17 display columns + 4 padding: two columns each side, wide glyphs - // starting right after the left padding. - let rect = app.view.tab_hit_areas[0]; - assert_eq!(rect.width, 21); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(rect.x, rect.y)].symbol(), " "); - assert_eq!(buffer[(rect.x + 1, rect.y)].symbol(), " "); - assert_eq!(buffer[(rect.x + 2, rect.y)].symbol(), "提"); - assert_eq!(buffer[(rect.x + rect.width - 2, rect.y)].symbol(), " "); - assert_eq!(buffer[(rect.x + rect.width - 1, rect.y)].symbol(), " "); - } - - #[test] - fn tab_labels_are_centered_in_their_cells() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("omarchy".into()); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let rect = app.view.tab_hit_areas[0]; - let buffer = terminal.backend().buffer(); - let cell: String = (rect.x..rect.x + rect.width) - .map(|x| buffer[(x, rect.y)].symbol()) - .collect(); - assert_eq!(cell, " omarchy "); - } - - #[test] - fn active_auto_named_tab_keeps_readable_weight() { - let mut app = AppState::test_new(); - let ws = Workspace::test_new("test"); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let tab_rect = app.view.tab_hit_areas[0]; - let style = terminal.backend().buffer()[(tab_rect.x + 1, tab_rect.y)].style(); - - assert_eq!(style.bg, Some(app.palette.accent)); - assert!(!style.add_modifier.contains(Modifier::DIM)); - assert!(!style.add_modifier.contains(Modifier::BOLD)); - } - - #[test] - fn zoom_marker_counts_toward_tab_width() { - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("abcdefgh".into()); - ws.tabs[0].zoomed = true; - - assert_eq!(tab_width(&ws, 0), 14); - } - - #[test] - fn tab_width_uses_display_width_for_cjk_labels() { - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("提交 herdr 的反馈".into()); - - assert_eq!( - tab_width(&ws, 0), - display_width_u16("提交 herdr 的反馈") + 4 - ); - } - - #[test] - fn tab_bar_renders_trailing_cjk_character() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("提交 herdr 的反馈".into()); - - app.active = Some(0); - app.workspaces = vec![ws]; - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0); - assert!(row.contains('馈'), "tab row: {row:?}"); - } -} diff --git a/src/ui/text.rs b/src/ui/text.rs index 45f7983b..9b5a7c68 100644 --- a/src/ui/text.rs +++ b/src/ui/text.rs @@ -4,10 +4,6 @@ pub(crate) fn display_width(text: &str) -> usize { UnicodeWidthStr::width(text) } -pub(crate) fn display_width_u16(text: &str) -> u16 { - display_width(text).min(u16::MAX as usize) as u16 -} - pub(crate) fn truncate_end(text: &str, max_width: usize) -> String { if display_width(text) <= max_width { return text.to_string(); @@ -23,22 +19,6 @@ pub(crate) fn truncate_end(text: &str, max_width: usize) -> String { format!("{prefix}…") } -pub(crate) fn middle_elide(text: &str, max_width: usize) -> String { - if display_width(text) <= max_width { - return text.to_string(); - } - if max_width <= 1 { - return "…".to_string(); - } - - let content_width = max_width.saturating_sub(1); - let left_width = content_width / 2; - let right_width = content_width.saturating_sub(left_width); - let prefix = take_prefix_width(text, left_width); - let suffix = take_suffix_width(text, right_width); - format!("{prefix}…{suffix}") -} - fn take_prefix_width(text: &str, max_width: usize) -> String { let mut output = String::new(); let mut width = 0usize; @@ -53,20 +33,6 @@ fn take_prefix_width(text: &str, max_width: usize) -> String { output } -fn take_suffix_width(text: &str, max_width: usize) -> String { - let mut output = Vec::new(); - let mut width = 0usize; - for ch in text.chars().rev() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if width + ch_width > max_width { - break; - } - output.push(ch); - width += ch_width; - } - output.into_iter().rev().collect() -} - #[cfg(test)] mod tests { use super::*; @@ -78,12 +44,4 @@ mod tests { assert_eq!(text, "提交 herdr 的反…"); assert!(display_width(&text) <= 16); } - - #[test] - fn middle_elide_uses_display_width() { - let text = middle_elide("重构用户认证模块并迁移到统一登录服务", 12); - - assert!(text.contains('…')); - assert!(display_width(&text) <= 12); - } } diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index ef416134..33fcc9a0 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -1,70 +1,30 @@ use ratatui::{ - layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, - Frame, + layout::{Constraint, Layout, Rect}, + style::Color, }; use crate::app::state::Palette; -pub(super) fn render_panel_shell( - frame: &mut Frame, - area: Rect, - border_color: Color, - bg: Color, -) -> Option { - if area.width < 2 || area.height < 2 { - return None; - } - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)) - .border_set(ratatui::symbols::border::PLAIN) - .style(Style::default().bg(bg)); - let inner = block.inner(area); - frame.render_widget(Clear, area); - frame.render_widget(block, area); - Some(inner) -} - -pub(super) fn panel_contrast_fg(p: &Palette) -> Color { - match p.panel_bg { - Color::Reset => p.surface_dim, +pub(super) fn panel_contrast_fg(palette: &Palette) -> Color { + match palette.panel_bg { + Color::Reset => palette.surface_dim, color => color, } } -pub(crate) fn centered_popup_rect(area: Rect, popup_w: u16, popup_h: u16) -> Option { - let popup_w = popup_w.min(area.width.saturating_sub(4)); - let popup_h = popup_h.min(area.height.saturating_sub(2)); - if popup_w < 4 || popup_h < 4 { +pub(crate) fn centered_popup_rect(area: Rect, popup_width: u16, popup_height: u16) -> Option { + let popup_width = popup_width.min(area.width.saturating_sub(4)); + let popup_height = popup_height.min(area.height.saturating_sub(2)); + if popup_width < 4 || popup_height < 4 { return None; } - let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; - Some(Rect::new(popup_x, popup_y, popup_w, popup_h)) -} - -pub(super) fn render_modal_shell( - frame: &mut Frame, - area: Rect, - popup_w: u16, - popup_h: u16, - p: &Palette, -) -> Option { - let popup = centered_popup_rect(area, popup_w, popup_h)?; - render_panel_shell(frame, popup, p.accent, p.panel_bg) -} - -pub(super) fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { - let line = Line::from(vec![Span::styled( - title, - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - )]); - frame.render_widget(Paragraph::new(line), area); + Some(Rect::new( + area.x + area.width.saturating_sub(popup_width) / 2, + area.y + area.height.saturating_sub(popup_height) / 2, + popup_width, + popup_height, + )) } #[derive(Debug, Clone, Copy)] @@ -110,169 +70,40 @@ pub(crate) fn modal_stack_areas( } let areas = Layout::vertical(constraints).split(inner); - let mut header = Rect::default(); - let mut content = Rect::default(); - let mut footer = None; - let mut actions = None; - + let mut result = ModalStackAreas { + header: Rect::default(), + content: Rect::default(), + footer: None, + actions: None, + }; for (slot, area) in slots.into_iter().zip(areas.iter().step_by(2).copied()) { match slot { - Slot::Header => header = area, - Slot::Content => content = area, - Slot::Footer => footer = Some(area), - Slot::Actions => actions = Some(area), + Slot::Header => result.header = area, + Slot::Content => result.content = area, + Slot::Footer => result.footer = Some(area), + Slot::Actions => result.actions = Some(area), } } - - ModalStackAreas { - header, - content, - footer, - actions, - } + result } -pub(crate) fn action_button_text(hint: Option<&str>, label: &str) -> String { +fn action_button_width(hint: Option<&str>, label: &str) -> u16 { match hint { - Some(hint) => format!(" {hint} {label} "), - None => format!(" {label} "), + Some(hint) => format!(" {hint} {label} ").chars().count() as u16, + None => format!(" {label} ").chars().count() as u16, } } -pub(crate) fn action_button_width(hint: Option<&str>, label: &str) -> u16 { - action_button_text(hint, label).chars().count() as u16 +pub(crate) fn close_button_rect(area: Rect) -> Rect { + let width = action_button_width(Some("esc"), "close"); + Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1) } -pub(crate) struct ActionButtonSpec<'a> { - pub hint: Option<&'a str>, - pub label: &'a str, -} - -pub(crate) fn action_button_row_rects( - area: Rect, - buttons: &[ActionButtonSpec<'_>], - gap: u16, - row_offset: u16, -) -> Vec { - let widths: Vec = buttons - .iter() - .map(|button| action_button_width(button.hint, button.label)) - .collect(); - centered_button_row(area, &widths, gap, row_offset) -} - -pub(super) fn render_action_button( - frame: &mut Frame, - rect: Rect, - hint: Option<&str>, - label: &str, - style: Style, -) { - frame.render_widget( - Paragraph::new(action_button_text(hint, label)) - .style(style) - .alignment(Alignment::Center), - rect, - ); -} - -pub(crate) fn render_modal_description(frame: &mut Frame, area: Rect, text: &str, style: Style) { - frame.render_widget( - Paragraph::new(format!(" {text}")) - .style(style) - .wrap(Wrap { trim: false }), - area, - ); -} - -pub(crate) fn modal_choice_rows(area: Rect, count: usize, row_height: u16) -> Vec { - let mut rows = Vec::with_capacity(count); - let mut y = area.y; - for _ in 0..count { - if y >= area.y + area.height { - break; - } - let remaining = area.y + area.height - y; - let height = row_height.min(remaining); - rows.push(Rect::new(area.x, y, area.width, height)); - y = y.saturating_add(row_height); - } - rows -} - -pub(crate) fn render_modal_choice_list( - frame: &mut Frame, - area: Rect, - title: &str, - description: &str, - options: &[(&str, T)], - current_value: T, - selected_idx: usize, - p: &Palette, - row_height: u16, -) where - T: Copy + PartialEq, -{ - let [desc_area, _, list_area] = Layout::vertical([ - Constraint::Length(2), - Constraint::Length(1), - Constraint::Min(2), - ]) - .areas::<3>(area); - - render_modal_description( - frame, - desc_area, - description, - Style::default().fg(p.overlay1), - ); - - let rows = modal_choice_rows(list_area, options.len(), row_height); - for (idx, ((label, value), row)) in options.iter().zip(rows.iter()).enumerate() { - let is_active = *value == current_value; - let is_selected = idx == selected_idx; - let marker = if is_active { " ✓" } else { "" }; - let style = if is_selected { - Style::default() - .bg(p.surface0) - .fg(p.text) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0) - }; - frame.render_widget( - Paragraph::new(format!(" {title}: {label}{marker}")) - .style(style) - .wrap(Wrap { trim: false }), - *row, - ); - } -} - -pub(super) fn centered_button_row( - inner: Rect, - widths: &[u16], - gap: u16, - row_offset: u16, -) -> Vec { - let total_w = widths - .iter() - .copied() - .sum::() - .saturating_add(gap.saturating_mul(widths.len().saturating_sub(1) as u16)); - let mut x = inner.x + inner.width.saturating_sub(total_w) / 2; - let y = inner.y + row_offset.min(inner.height.saturating_sub(1)); - widths - .iter() - .map(|w| { - let rect = Rect::new( - x, - y, - (*w).min(inner.width.saturating_sub(x.saturating_sub(inner.x))), - 1, - ); - x = x.saturating_add(*w).saturating_add(gap); - rect - }) - .collect() +pub(crate) fn continue_button_rect(area: Rect) -> Rect { + Rect::new( + area.x, + area.y, + action_button_width(Some("↵"), "continue"), + 1, + ) } diff --git a/src/workspace.rs b/src/workspace.rs index 0a719ab1..b4bba45d 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -19,8 +19,6 @@ mod aggregate; mod git; mod tab; -#[cfg(test)] -use self::git::git_ahead_behind; use self::git::git_status_cache_key_for_space; pub(crate) use self::{git::git_status_snapshot_for_cwd_with_demand, tab::MovedPane}; pub use self::{ @@ -274,9 +272,6 @@ impl Workspace { } } - // Test modules construct workspaces through the default constructor; production paths - // use the env-aware variant so pane identity env is always explicit. - #[cfg_attr(not(test), allow(dead_code))] pub fn new( initial_cwd: PathBuf, rows: u16, @@ -289,7 +284,7 @@ impl Workspace { render_notify: Arc, render_dirty: Arc, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { - Self::new_with_extra_env( + Self::new_with_tab( initial_cwd, rows, cols, @@ -300,6 +295,7 @@ impl Workspace { events, render_notify, render_dirty, + None, Vec::new(), ) } @@ -318,6 +314,20 @@ impl Workspace { render_dirty: Arc, extra_env: Vec<(String, String)>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { + if extra_env.is_empty() { + return Self::new( + initial_cwd, + rows, + cols, + scrollback_limit_bytes, + host_terminal_theme, + host_terminal_appearance, + shell_config, + events, + render_notify, + render_dirty, + ); + } Self::new_with_tab( initial_cwd, rows, @@ -334,65 +344,6 @@ impl Workspace { ) } - // Kept for tests that do not need launch-env customization. - #[allow(dead_code)] - pub fn new_argv_command( - initial_cwd: PathBuf, - rows: u16, - cols: u16, - argv: &[String], - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - host_terminal_appearance: Option, - events: mpsc::Sender, - render_notify: Arc, - render_dirty: Arc, - ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { - Self::new_argv_command_with_extra_env( - initial_cwd, - rows, - cols, - argv, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - events, - render_notify, - render_dirty, - Vec::new(), - ) - } - - #[allow(clippy::too_many_arguments)] - pub fn new_argv_command_with_extra_env( - initial_cwd: PathBuf, - rows: u16, - cols: u16, - argv: &[String], - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - host_terminal_appearance: Option, - events: mpsc::Sender, - render_notify: Arc, - render_dirty: Arc, - extra_env: Vec<(String, String)>, - ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { - Self::new_with_tab( - initial_cwd, - rows, - cols, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), - events, - render_notify, - render_dirty, - Some(argv), - extra_env, - ) - } - #[allow(clippy::too_many_arguments)] fn new_with_tab( initial_cwd: PathBuf, @@ -671,43 +622,6 @@ impl Workspace { self.close_tab(self.active_tab) } - #[cfg(test)] - pub fn split_focused( - &mut self, - direction: Direction, - rows: u16, - cols: u16, - cwd: Option, - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - host_terminal_appearance: Option, - shell_config: crate::pane::PaneShellConfig<'_>, - extra_env: Vec<(String, String)>, - ) -> std::io::Result { - let pane_number = self.next_public_pane_number; - let tab_number = self - .active_tab() - .map(|tab| tab.number) - .expect("workspace must always have at least one tab"); - let launch_env = self.launch_env_for_new_pane(tab_number, pane_number, extra_env); - let new_pane = self - .active_tab_mut() - .expect("workspace must always have at least one tab") - .split_focused( - direction, - rows, - cols, - cwd, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - shell_config, - &launch_env, - )?; - self.register_new_pane_with_number(new_pane.pane_id, pane_number); - Ok(new_pane) - } - #[allow(clippy::too_many_arguments)] pub fn split_focused_command( &mut self, @@ -1174,14 +1088,6 @@ impl Workspace { self.worktree_space.as_ref() } - #[cfg(test)] - pub fn refresh_git_ahead_behind(&mut self) { - let cwd = self.resolved_identity_cwd(); - self.cached_git_branch = cwd.as_deref().and_then(git_branch); - self.cached_git_ahead_behind = cwd.as_deref().and_then(git_ahead_behind); - self.cached_git_space = cwd.as_deref().and_then(git_space_metadata); - } - pub fn find_tab_index_for_pane(&self, pane_id: PaneId) -> Option { self.tabs .iter() @@ -1663,50 +1569,6 @@ mod tests { assert!(!target.tabs[0].panes.contains_key(&source_pane)); } - #[tokio::test] - async fn new_workspace_retains_discovered_git_metadata() { - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock should be after unix epoch") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "herdr-workspace-git-metadata-{}-{stamp}", - std::process::id() - )); - std::fs::create_dir_all(root.join(".git")).expect("create git directory"); - std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write git head"); - #[cfg(windows)] - let command = "C:\\Windows\\System32\\whoami.exe"; - #[cfg(not(windows))] - let command = "/usr/bin/true"; - let argv = vec![command.to_string()]; - let (events, _) = mpsc::channel(64); - let render_notify = Arc::new(Notify::new()); - let render_dirty = Arc::new(RenderSignal::new()); - - let (workspace, _terminal, runtime) = Workspace::new_argv_command( - root.clone(), - 24, - 80, - &argv, - 1024, - crate::terminal_theme::TerminalTheme::default(), - None, - events, - render_notify, - render_dirty, - ) - .expect("create workspace"); - - let space = workspace - .git_space() - .expect("workspace should retain discovered git metadata"); - assert_eq!(space.repo_root, root); - - runtime.shutdown(); - std::fs::remove_dir_all(root).expect("remove test repo"); - } - #[test] fn linked_worktree_auto_label_uses_checkout_name_not_repo_name() { let (base, repo, checkout) = diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs index 29621d4d..3fd45fca 100644 --- a/src/workspace/aggregate.rs +++ b/src/workspace/aggregate.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::detect::{Agent, AgentState}; +use crate::detect::AgentState; use crate::layout::PaneId; use crate::terminal::{TerminalId, TerminalState}; @@ -10,18 +10,10 @@ use super::{Tab, Workspace}; pub struct PaneDetail { pub pane_id: PaneId, pub tab_idx: usize, - pub tab_label: String, - pub label: String, - pub pane_label: Option, - pub terminal_title: Option, - pub terminal_title_stripped: Option, - pub agent_label: String, pub agent_kind_label: Option, - pub agent: Option, pub state: AgentState, pub seen: bool, pub last_agent_state_change_seq: Option, - pub state_labels: HashMap, pub tokens: HashMap, } @@ -30,7 +22,6 @@ impl Tab { &self, terminals: &HashMap, tab_idx: usize, - tab_label: &str, ) -> Vec { self.layout .pane_ids() @@ -39,32 +30,16 @@ impl Tab { let pane = self.panes.get(id)?; let terminal = terminals.get(&pane.attached_terminal_id)?; let agent_kind_label = terminal.effective_agent_label().map(str::to_string); - let fallback_agent_label = terminal - .agent_name - .as_deref() - .or(agent_kind_label.as_deref())? - .to_string(); - let agent_label = terminal - .effective_display_agent() - .unwrap_or_else(|| fallback_agent_label.clone()); - let presentation = terminal.effective_presentation(); + if terminal.agent_name.is_none() && agent_kind_label.is_none() { + return None; + } Some(PaneDetail { pane_id: *id, tab_idx, - tab_label: tab_label.to_string(), - label: agent_label.clone(), - pane_label: terminal - .effective_title() - .or_else(|| terminal.manual_label.clone()), - terminal_title: terminal.terminal_title.clone(), - terminal_title_stripped: terminal.terminal_title_stripped(), - agent_label, agent_kind_label, - agent: terminal.effective_known_agent(), state: terminal.state, seen: pane.seen, last_agent_state_change_seq: terminal.last_agent_state_change_seq, - state_labels: presentation.state_labels, tokens: terminal.metadata_tokens.values(), }) }) @@ -100,22 +75,10 @@ impl Workspace { } pub fn pane_details(&self, terminals: &HashMap) -> Vec { - let multi_tab = self.tabs.len() > 1; self.tabs .iter() .enumerate() - .flat_map(|(tab_idx, tab)| { - let tab_label = self - .tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()); - tab.pane_details(terminals, tab_idx, &tab_label).into_iter() - }) - .map(|mut detail| { - if multi_tab { - detail.label = format!("{}·{}", detail.tab_label, detail.agent_label); - } - detail - }) + .flat_map(|(tab_idx, tab)| tab.pane_details(terminals, tab_idx)) .collect() } } @@ -193,70 +156,6 @@ mod tests { assert!(!seen); } - #[test] - fn pane_details_prefers_agent_name_over_detected_agent_label() { - let ws = Workspace::test_new("test"); - let root_pane = ws.tabs[0].root_pane; - let mut terminals = HashMap::new(); - let mut terminal = terminal_for_pane(&ws, root_pane); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); - terminal.set_agent_name("planner".into()); - terminals.insert(terminal.id.clone(), terminal); - - let labels: Vec<_> = ws - .pane_details(&terminals) - .into_iter() - .map(|detail| (detail.label, detail.agent_label, detail.agent)) - .collect(); - - assert_eq!( - labels, - vec![("planner".into(), "planner".into(), Some(Agent::Pi))] - ); - } - - #[test] - fn pane_details_includes_tab_context_for_multi_tab_workspace() { - let mut ws = Workspace::test_new("test"); - ws.tabs[0].custom_name = Some("main".into()); - let root_pane = ws.tabs[0].root_pane; - let second_tab = ws.test_add_tab(Some("review")); - let review_pane = ws.tabs[second_tab].root_pane; - let mut terminals = HashMap::new(); - let mut root_terminal = terminal_for_pane(&ws, root_pane); - root_terminal.set_hook_authority( - "test".into(), - "pi".into(), - AgentState::Working, - None, - None, - ); - terminals.insert(root_terminal.id.clone(), root_terminal); - let mut review_terminal = terminal_for_pane(&ws, review_pane); - review_terminal.set_hook_authority( - "test".into(), - "claude".into(), - AgentState::Idle, - None, - None, - ); - terminals.insert(review_terminal.id.clone(), review_terminal); - - let labels: Vec<_> = ws - .pane_details(&terminals) - .into_iter() - .map(|detail| (detail.label, detail.agent_label, detail.agent)) - .collect(); - - assert_eq!( - labels, - vec![ - ("main·pi".into(), "pi".into(), Some(Agent::Pi)), - ("review·claude".into(), "claude".into(), Some(Agent::Claude)), - ] - ); - } - #[test] fn pane_details_use_tab_vector_index_not_stable_public_tab_number() { let mut ws = Workspace::test_new("test"); diff --git a/src/workspace/git/mod.rs b/src/workspace/git/mod.rs index b00cf3b3..b17cd347 100644 --- a/src/workspace/git/mod.rs +++ b/src/workspace/git/mod.rs @@ -18,6 +18,3 @@ pub use self::{ git_status_snapshot_for_cwd_with_demand, GitStatusCacheEntry, GitStatusRefreshDemand, }, }; - -#[cfg(test)] -pub(super) use self::status::git_ahead_behind; diff --git a/src/workspace/git/status.rs b/src/workspace/git/status.rs index 9f40170c..1c29d75c 100644 --- a/src/workspace/git/status.rs +++ b/src/workspace/git/status.rs @@ -314,25 +314,6 @@ fn read_upstream(repo: &mut RepoContext, branch: &str) -> Option Option<(usize, usize)> { - super::discovery::git_repo_root(cwd)?; - - let output = std::process::Command::new("git") - .arg("-C") - .arg(cwd) - .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8(output.stdout).ok()?; - parse_git_ahead_behind_output(&stdout) -} - fn git_ahead_behind_between( cwd: &Path, head_oid: &str, diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index 6f0fa75e..79d7f94d 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -205,36 +205,6 @@ impl Tab { self.custom_name = Some(name); } - #[cfg(test)] - pub fn split_focused( - &mut self, - direction: Direction, - rows: u16, - cols: u16, - cwd: Option, - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - host_terminal_appearance: Option, - shell_config: crate::pane::PaneShellConfig<'_>, - launch_env: &PaneLaunchEnv, - ) -> std::io::Result { - self.split_pane_with_runtime( - self.layout.focused(), - true, - direction, - None, - rows, - cols, - cwd, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - shell_config, - launch_env, - None, - ) - } - pub fn split_focused_command( &mut self, direction: Direction, diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 0a52e6a7..b8823ac4 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -1,4 +1,4 @@ -mod support; +pub mod support; use std::fs; use std::io::{Read, Write}; diff --git a/tests/auto_detect.rs b/tests/auto_detect.rs index f3f09533..49473dbb 100644 --- a/tests/auto_detect.rs +++ b/tests/auto_detect.rs @@ -2,7 +2,7 @@ #![cfg(not(target_os = "macos"))] -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Write}; diff --git a/tests/cli.rs b/tests/cli.rs index c7eb2834..ff238996 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,6 @@ #![cfg(not(target_os = "macos"))] -mod support; +pub mod support; #[path = "cli/mod.rs"] mod cases; diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 2b057e69..05b06a19 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -2,7 +2,7 @@ #![cfg(unix)] -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; @@ -13,12 +13,13 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -use serde::Deserialize; use serde_json::Value; use support::{ - cleanup_test_base, client_handshake, encode_varint_u32, frame_message, read_server_message, - register_runtime_dir, register_spawned_herdr_pid, unregister_spawned_herdr_pid, + cleanup_test_base, client_shell_handshake, read_server_message, register_runtime_dir, + register_spawned_herdr_pid, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap, wait_for_message_variant, wait_for_socket, wait_until, CURRENT_PROTOCOL, + SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_SEMANTIC_NOTIFICATION, + SERVER_MESSAGE_SERVER_SHUTDOWN, }; fn unique_test_dir() -> PathBuf { @@ -247,100 +248,12 @@ fn app_dir_name() -> &'static str { } } -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct FrameWire { - cells: Vec, - width: u16, - height: u16, - cursor: Option, - hyperlinks: Vec, - graphics: Vec, -} - -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct CellWire { - symbol: String, - fg: u32, - bg: u32, - modifier: u16, - skip: bool, - hyperlink: Option, -} - -#[derive(Debug, Deserialize)] -struct CursorWire { - x: u16, - y: u16, - visible: bool, - shape: u8, -} - -fn decode_frame_payload(payload: &[u8]) -> std::io::Result { - bincode::serde::decode_from_slice(payload, bincode::config::standard()) - .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string())) - .and_then(|(frame, consumed): (FrameWire, usize)| { - if consumed != payload.len() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!( - "frame payload had trailing bytes: consumed={}, len={}", - consumed, - payload.len() - ), - )); - } - Ok(frame) - }) -} - -fn read_next_frame_payload(stream: &mut UnixStream, timeout: Duration) -> Result, String> { - stream - .set_read_timeout(Some(Duration::from_millis(200))) - .map_err(|e| e.to_string())?; - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - match read_server_message(stream) { - Ok((1, payload)) => return Ok(payload), - Ok(_) => continue, - Err(_) => continue, - } - } - Err("timed out waiting for Frame message".into()) -} - -fn frame_text(frame: &FrameWire) -> String { - if frame.cells.is_empty() { - return String::new(); - } - - let width = frame.width.max(1) as usize; - let mut text = String::new(); - for row in frame.cells.chunks(width) { - for cell in row { - let _ = (cell.fg, cell.bg, cell.modifier, cell.skip); - text.push_str(&cell.symbol); - } - text.push('\n'); - } - let _ = (frame.height, frame.graphics.len()); - if let Some(cursor) = frame.cursor.as_ref() { - let _ = (cursor.x, cursor.y, cursor.visible, cursor.shape); - } - - text -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[test] -fn client_connects_and_receives_frame() { - // Client connects to server and handshake completes. - // Client receives Frame messages. - // Server sends rendered frames to connected clients. +fn client_connects_and_receives_pane_surface() { let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); @@ -352,22 +265,13 @@ fn client_connects_and_receives_frame() { wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); - // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); - assert_eq!( - version, CURRENT_PROTOCOL, - "server should report current protocol version" - ); - assert!( - error.is_none(), - "handshake should not have error: {:?}", - error - ); - - read_next_frame_payload(&mut stream, Duration::from_secs(10)) - .expect("should receive a frame from server"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23) + .expect("handshake should succeed"); + assert_eq!(version, CURRENT_PROTOCOL); + assert!(error.is_none(), "{error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(10)) + .expect("should receive the shell snapshot and pane surface"); cleanup_spawned_herdr(spawned, base); } @@ -541,41 +445,26 @@ fn client_sees_headless_startup_config_diagnostic() { wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); - let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); - assert_eq!(version, CURRENT_PROTOCOL); - assert!(error.is_none(), "{:?}", error); - - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - let mut found_diagnostic = false; - let mut last_frame_text = String::new(); - while Instant::now() < deadline { - match read_server_message(&mut stream) { - Ok((1, payload)) => { - let frame = decode_frame_payload(&payload).expect("decode frame"); - last_frame_text = frame_text(&frame); - if last_frame_text.contains("config.toml") - && last_frame_text.contains("herdr config check") - { - found_diagnostic = true; - break; - } - } - Ok(_) => {} - Err(_) => break, - } - } - + let client = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket); + let output = spawn_pty_drain( + client + ._master + .as_ref() + .expect("client shell master") + .try_clone_reader() + .expect("clone client shell reader"), + ); assert!( - found_diagnostic, - "attached client should see startup config parse diagnostic; last frame:\n{last_frame_text}" + wait_until(Duration::from_secs(8), Duration::from_millis(20), || { + let output = read_output(&output); + output.contains("config.toml") && output.contains("herdr config check") + }), + "client shell should render startup config diagnostic; output: {:?}", + read_output(&output) ); - cleanup_spawned_herdr(spawned, base); + drop(spawned); + cleanup_spawned_herdr(client, base); } #[test] @@ -1383,14 +1272,7 @@ fn client_exits_cleanly_when_terminal_hangs_up() { } #[test] -fn client_receives_frame_after_pane_output() { - // End-to-end test: server renders, client receives Frame. - // This test verifies the full flow: - // 1. Start server - // 2. Connect client, handshake - // 3. Send input to pane (echo command) - // 4. Wait for a new frame from the server - // 5. Verify the frame contains the pane output +fn client_receives_pane_surface_after_pane_output() { let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); @@ -1402,33 +1284,52 @@ fn client_receives_frame_after_pane_output() { wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); - // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); - assert!(error.is_none(), "{:?}", error); + assert!(error.is_none(), "{error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(10)) + .expect("initial client shell bootstrap"); - read_next_frame_payload(&mut stream, Duration::from_secs(10)) - .expect("should receive initial frame"); + let created = send_json_request( + &api_socket, + &serde_json::json!({ + "id": "create-output-workspace", + "method": "workspace.create", + "params": {"label": "output", "focus": true} + }) + .to_string(), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .expect("root pane id"); + assert!(wait_for_message_variant( + &mut stream, + Duration::from_secs(5), + SERVER_MESSAGE_PANE_SURFACE, + ) + .expect("wait for created workspace surface")); - // Send input to trigger a state change and re-render. - let input_data = b"echo test-output\n".to_vec(); - let input_payload = { - let mut buf = encode_varint_u32(1); // Input variant - buf.extend_from_slice(&encode_varint_u32(input_data.len() as u32)); - buf.extend_from_slice(&input_data); - buf - }; - let framed = frame_message(&input_payload); - stream.write_all(&framed).expect("send input"); - stream.flush().expect("flush"); - - // Read subsequent frames — the server should have re-rendered after - // the input was processed. - let received_frame = wait_for_message_variant(&mut stream, Duration::from_secs(2), 1) - .expect("wait for post-output frame"); - assert!(received_frame, "should receive a Frame after pane output"); + let sent = send_json_request( + &api_socket, + &serde_json::json!({ + "id": "send-output", + "method": "pane.send_text", + "params": {"pane_id": pane_id, "text": "printf 'test-output\\n'\\n"} + }) + .to_string(), + ); + assert!(sent.get("error").is_none(), "{sent}"); + assert!( + wait_for_message_variant( + &mut stream, + Duration::from_secs(5), + SERVER_MESSAGE_PANE_SURFACE, + ) + .expect("wait for post-output pane surface"), + "should receive a pane surface after pane output" + ); cleanup_spawned_herdr(spawned, base); } @@ -1535,18 +1436,13 @@ fn graceful_shutdown_sends_server_shutdown_to_client() { wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); - // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); - assert!(error.is_none(), "{:?}", error); - - // Drain initial frame(s). - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - while read_server_message(&mut stream).is_ok() {} + assert!(error.is_none(), "{error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)) + .expect("client shell bootstrap"); // Send SIGINT to the server process to trigger graceful shutdown. if let Some(pid) = spawned.child.process_id() { @@ -1555,7 +1451,7 @@ fn graceful_shutdown_sends_server_shutdown_to_client() { } } - // The client should receive a ServerShutdown message (variant 4) + // The client should receive a ServerShutdown message // before the connection is closed, not just an abrupt EOF. stream .set_read_timeout(Some(Duration::from_secs(5))) @@ -1564,8 +1460,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() { match result { Ok((variant, _payload)) => { assert_eq!( - variant, 4, - "expected ServerShutdown (variant 4), got variant {variant}" + variant, SERVER_MESSAGE_SERVER_SHUTDOWN, + "expected ServerShutdown, got variant {variant}" ); } Err(e) => { @@ -1594,9 +1490,9 @@ fn client_receives_notify_on_agent_state_change() { let client_socket = runtime_dir.join("herdr-client.sock"); // Enable toast and sound in config so the server produces notifications. - fs::create_dir_all(config_home.join("herdr")).unwrap(); + fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); fs::write( - config_home.join("herdr/config.toml"), + config_home.join(app_dir_name()).join("config.toml"), "onboarding = false\n[ui.toast]\nenabled = true\n[ui.sound]\nenabled = true\n", ) .unwrap(); @@ -1634,18 +1530,13 @@ fn client_receives_notify_on_agent_state_change() { wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); - // Connect as a client and perform handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); - assert!(error.is_none(), "{:?}", error); - - // Drain initial frame(s). - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - while read_server_message(&mut stream).is_ok() {} + assert!(error.is_none(), "{error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)) + .expect("client shell bootstrap"); // Create a workspace via the API. let mut ws_stream = UnixStream::connect(&api_socket).expect("connect to API"); @@ -1689,8 +1580,7 @@ fn client_receives_notify_on_agent_state_change() { let mut report_response = String::new(); report_reader.read_line(&mut report_response).unwrap(); - // Read messages from the client stream and look for Notify (variant 5). - // Notify = ServerMessage variant index 5. + // Read messages from the client stream and look for the semantic notification. stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); @@ -1699,12 +1589,11 @@ fn client_receives_notify_on_agent_state_change() { while Instant::now() < deadline { match read_server_message(&mut stream) { Ok((variant, _payload)) => { - if variant == 5 { - // ServerMessage::Notify — found it! + if variant == SERVER_MESSAGE_SEMANTIC_NOTIFICATION { found_notify = true; break; } - // Continue reading — Frame messages (variant 1) will come first. + // Snapshot and pane-surface messages may arrive first. } Err(_) => { break; @@ -1714,7 +1603,7 @@ fn client_receives_notify_on_agent_state_change() { assert!( found_notify, - "client should receive a ServerMessage::Notify after pane.report_agent" + "client should receive a semantic notification after pane.report_agent" ); // Now report Idle from Working — this should trigger a Done sound @@ -1776,7 +1665,7 @@ fn client_receives_notify_on_agent_state_change() { let mut idle_response = String::new(); idle_reader.read_line(&mut idle_response).unwrap(); - // Read messages and look for Done sound notify. + // Read messages and look for the done semantic notification. stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); @@ -1785,16 +1674,14 @@ fn client_receives_notify_on_agent_state_change() { while Instant::now() < deadline { match read_server_message(&mut stream) { Ok((variant, _payload)) => { - if variant == 5 { - // Found a Notify message — that's good enough. - // The test already verified the Blocked→Notify path above. + if variant == SERVER_MESSAGE_SEMANTIC_NOTIFICATION { found_done_notify = true; break; } - // Continue reading — Frame messages will come first. + // Snapshot and pane-surface messages may arrive first. } Err(e) => { - eprintln!("read error while looking for Done Notify: {e}"); + eprintln!("read error while looking for done notification: {e}"); break; } } @@ -1802,7 +1689,7 @@ fn client_receives_notify_on_agent_state_change() { assert!( found_done_notify, - "client should receive a Sound Notify with 'agent done' when background pane transitions Working→Idle" + "client should receive a semantic notification when a background pane transitions Working→Idle" ); cleanup_spawned_herdr(spawned, base); diff --git a/tests/cross_area.rs b/tests/cross_area.rs index 6f1ab82b..45fc01c0 100644 --- a/tests/cross_area.rs +++ b/tests/cross_area.rs @@ -1,6 +1,6 @@ //! Cross-area integration tests for end-to-end persistence flows. -mod support; +pub mod support; use std::fs; use std::io::{self, BufRead, BufReader, Read, Write}; @@ -11,11 +11,11 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -use serde::Deserialize; use serde_json::{json, Value}; use support::{ - cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid, - unregister_spawned_herdr_pid, CURRENT_PROTOCOL, + cleanup_test_base, client_shell_handshake, register_runtime_dir, register_spawned_herdr_pid, + unregister_spawned_herdr_pid, CURRENT_PROTOCOL, SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, + SERVER_MESSAGE_PANE_SURFACE, }; fn unique_test_dir() -> PathBuf { @@ -196,7 +196,7 @@ fn workspace_create(socket_path: &Path, label: &str) -> Value { socket_path, "workspace_create", "workspace.create", - json!({ "label": label }), + json!({ "label": label, "focus": true }), ) } @@ -365,16 +365,6 @@ fn encode_varint_u32(v: u32) -> Vec { } } -fn encode_varint_u16(v: u16) -> Vec { - if v < 251 { - vec![v as u8] - } else { - let mut buf = vec![251u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - fn frame_message(payload: &[u8]) -> Vec { let mut framed = (payload.len() as u32).to_le_bytes().to_vec(); framed.extend_from_slice(payload); @@ -414,76 +404,6 @@ fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), Stri } } -fn client_handshake(stream: &mut UnixStream, version: u32, cols: u16, rows: u16) { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .expect("set read timeout"); - - // ClientMessage::Hello = variant 0 - let mut payload = encode_varint_u32(0); - payload.extend_from_slice(&encode_varint_u32(version)); - payload.extend_from_slice(&encode_varint_u16(cols)); - payload.extend_from_slice(&encode_varint_u16(rows)); - payload.extend_from_slice(&encode_varint_u32(8)); // cell_width_px - payload.extend_from_slice(&encode_varint_u32(16)); // cell_height_px - payload.extend_from_slice(&encode_varint_u32(0)); // RenderEncoding::SemanticFrame - payload.extend_from_slice(&encode_varint_u32(0)); // ClientKeybindings::Server - payload.extend_from_slice(&encode_varint_u32(0)); // ClientLaunchMode::App - - stream - .write_all(&frame_message(&payload)) - .expect("write hello"); - stream.flush().expect("flush hello"); - - let mut len_buf = [0u8; 4]; - stream - .read_exact(&mut len_buf) - .expect("read welcome length"); - let len = u32::from_le_bytes(len_buf) as usize; - assert!(len > 0 && len <= 2 * 1024 * 1024, "unexpected welcome size"); - - let mut welcome_payload = vec![0u8; len]; - stream - .read_exact(&mut welcome_payload) - .expect("read welcome payload"); - - let mut offset = 0; - let (variant, consumed) = decode_varint_u32(&welcome_payload, offset).expect("decode variant"); - offset += consumed; - assert_eq!(variant, 0, "expected ServerMessage::Welcome variant"); - - let (_server_version, consumed) = - decode_varint_u32(&welcome_payload, offset).expect("decode version"); - offset += consumed; - - let (_encoding, consumed) = - decode_varint_u32(&welcome_payload, offset).expect("decode render encoding"); - offset += consumed; - - let option_tag = *welcome_payload - .get(offset) - .expect("welcome payload should contain Option tag"); - if option_tag == 1 { - let (str_len, consumed) = - decode_varint_u32(&welcome_payload, offset + 1).expect("decode error length"); - let start = offset + 1 + consumed; - let end = start + str_len as usize; - let err = String::from_utf8(welcome_payload[start..end].to_vec()).expect("utf8 error"); - panic!("handshake rejected: {err}"); - } -} - -fn send_client_input(stream: &mut UnixStream, data: &[u8]) { - // ClientMessage::Input = variant 1 - let mut payload = encode_varint_u32(1); - payload.extend_from_slice(&encode_varint_u32(data.len() as u32)); - payload.extend_from_slice(data); - stream - .write_all(&frame_message(&payload)) - .expect("write input"); - stream.flush().expect("flush input"); -} - fn send_client_detach(stream: &mut UnixStream) { // ClientMessage::Detach = variant 4 let payload = encode_varint_u32(4); @@ -500,88 +420,8 @@ fn is_timeout(err: &io::Error) -> bool { ) } -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct FrameWire { - cells: Vec, - width: u16, - height: u16, - cursor: Option, - hyperlinks: Vec, - graphics: Vec, -} - -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct CellWire { - symbol: String, - fg: u32, - bg: u32, - modifier: u16, - skip: bool, - hyperlink: Option, -} - -#[derive(Debug, Deserialize)] -struct CursorWire { - x: u16, - y: u16, - visible: bool, - shape: u8, -} - -fn decode_frame_payload(payload: &[u8]) -> io::Result { - bincode::serde::decode_from_slice(payload, bincode::config::standard()) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string())) - .and_then(|(frame, consumed): (FrameWire, usize)| { - if consumed != payload.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "frame payload had trailing bytes: consumed={}, len={}", - consumed, - payload.len() - ), - )); - } - Ok(frame) - }) -} - -fn frame_contains_colored_symbol(frame: &FrameWire, symbol: &str, rgb: (u8, u8, u8)) -> bool { - let (r, g, b) = rgb; - let fg = 0x02_00_00_00 | (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b); - frame - .cells - .iter() - .any(|cell| cell.symbol == symbol && cell.fg == fg) -} - -fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool { - if frame.cells.is_empty() { - return false; - } - - let width = frame.width.max(1) as usize; - let mut text = String::new(); - for row in frame.cells.chunks(width) { - for cell in row { - let _ = (cell.fg, cell.bg, cell.modifier, cell.skip); - text.push_str(&cell.symbol); - } - text.push('\n'); - } - let _ = (frame.height, frame.graphics.len()); - if let Some(cursor) = frame.cursor.as_ref() { - let _ = (cursor.x, cursor.y, cursor.visible, cursor.shape); - } - - text.contains(needle) -} - fn read_server_variant(stream: &mut UnixStream, timeout: Duration) -> io::Result { stream.set_read_timeout(Some(timeout))?; - let mut len_buf = [0u8; 4]; stream.read_exact(&mut len_buf)?; let len = u32::from_le_bytes(len_buf) as usize; @@ -591,73 +431,20 @@ fn read_server_variant(stream: &mut UnixStream, timeout: Duration) -> io::Result "zero-length payload", )); } - let mut payload = vec![0u8; len]; stream.read_exact(&mut payload)?; - - let (variant, _consumed) = decode_varint_u32(&payload, 0) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - Ok(variant) -} - -fn read_server_message_payload( - stream: &mut UnixStream, - timeout: Duration, -) -> io::Result<(u32, Vec)> { - stream.set_read_timeout(Some(timeout))?; - - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; - if len == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "zero-length payload", - )); - } - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload)?; - - let (variant, consumed) = decode_varint_u32(&payload, 0) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - Ok((variant, payload[consumed..].to_vec())) -} - -fn wait_for_frame_matching( - stream: &mut UnixStream, - timeout: Duration, - predicate: impl Fn(&FrameWire) -> bool, -) -> io::Result { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - let slice = deadline - .saturating_duration_since(Instant::now()) - .min(Duration::from_millis(80)); - match read_server_message_payload(stream, slice) { - Ok((1, payload)) => { - let frame = decode_frame_payload(&payload)?; - if predicate(&frame) { - return Ok(true); - } - } - Ok((_variant, _payload)) => {} - Err(err) if is_timeout(&err) => {} - Err(err) => return Err(err), - } - } - - Ok(false) + decode_varint_u32(&payload, 0) + .map(|(variant, _)| variant) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) } fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; while Instant::now() < deadline { - let slice = deadline - .saturating_duration_since(Instant::now()) - .min(Duration::from_millis(80)); + let slice = deadline.saturating_duration_since(Instant::now()); match read_server_variant(stream, slice) { - Ok(1) => return true, // ServerMessage::Frame + Ok(SERVER_MESSAGE_PANE_SURFACE) => return true, + Ok(SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT) => {} Ok(_) => {} Err(err) if is_timeout(&err) => {} Err(_) => return false, @@ -669,7 +456,7 @@ fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool { fn drain_server_messages(stream: &mut UnixStream, max_drain: Duration) { let deadline = Instant::now() + max_drain; while Instant::now() < deadline { - match read_server_variant(stream, Duration::from_millis(40)) { + match read_server_variant(stream, deadline.saturating_duration_since(Instant::now())) { Ok(_) => {} Err(err) if is_timeout(&err) => break, Err(_) => break, @@ -696,7 +483,7 @@ fn cross_area_detach_and_reattach_preserves_state() { // Local attach (client A). let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30); + client_shell_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30).expect("shell handshake"); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); // Use herdr: create a workspace and write output into its pane. @@ -733,7 +520,7 @@ fn cross_area_detach_and_reattach_preserves_state() { // Reattach from another terminal/session (client B). let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24); + client_shell_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24).expect("shell handshake"); assert!( wait_for_frame(&mut client_b, Duration::from_secs(5)), "reattached client should receive frame" @@ -789,7 +576,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30); + client_shell_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30).expect("shell handshake"); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); let created = workspace_create(&api_socket, "agent-persist"); @@ -840,18 +627,10 @@ fn cross_area_agent_process_survives_detach_and_reattach() { "agent status should remain working while detached" ); - // Reattach and ensure client-side state reflects the persisted working status. + // Reattach and verify the persisted projection through the API. let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24); - let saw_working_on_client = - wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| { - frame_contains_colored_symbol(frame, "●", (249, 226, 175)) - }) - .expect("frame decoding should succeed"); - assert!( - saw_working_on_client, - "reattached client frame should expose persisted agent working status" - ); + client_shell_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24).expect("shell handshake"); + assert!(wait_for_frame(&mut client_b, Duration::from_secs(5))); // Transition to blocked and verify API + client surfaces both observe it. // The fake process remains visibly working, so blocked is the deterministic @@ -862,15 +641,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() { "pane agent status should transition to blocked" ); - let saw_blocked_on_client = - wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| { - frame_contains_colored_symbol(frame, "●", (243, 139, 168)) - }) - .expect("frame decoding should succeed"); - assert!( - saw_blocked_on_client, - "reattached client frame should show blocked status after transition" - ); + // The API status above is the stable cross-area contract for this transition. cleanup_spawned_herdr(server, base); } @@ -889,7 +660,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client = UnixStream::connect(&client_socket).expect("client should connect"); - client_handshake(&mut client, CURRENT_PROTOCOL, 100, 30); + client_shell_handshake(&mut client, CURRENT_PROTOCOL, 100, 30).expect("shell handshake"); assert!(wait_for_frame(&mut client, Duration::from_secs(2))); drain_server_messages(&mut client, Duration::from_millis(300)); @@ -902,17 +673,8 @@ fn cross_area_client_and_api_workspace_views_are_consistent() { .expect("workspace.create should return workspace_id") .to_string(); - // The attached client must receive a frame that includes the new workspace - // label, proving client-side state reflects the API surface. - let saw_workspace_on_client = - wait_for_frame_matching(&mut client, Duration::from_secs(3), |frame| { - frame_contains_text(frame, "api-visible-workspace") - }) - .expect("frame decoding should succeed"); - assert!( - saw_workspace_on_client, - "client-side frame should include the newly created workspace label" - ); + // ClientShell state is asserted through the authoritative API projection below; + // do not decode client-composed UI to duplicate that assertion. let deadline = Instant::now() + Duration::from_secs(5); let mut count_reached = false; @@ -952,9 +714,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, CURRENT_PROTOCOL, 110, 30); + client_shell_handshake(&mut client_a, CURRENT_PROTOCOL, 110, 30).expect("shell handshake"); let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, CURRENT_PROTOCOL, 100, 30); + client_shell_handshake(&mut client_b, CURRENT_PROTOCOL, 100, 30).expect("shell handshake"); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); assert!(wait_for_frame(&mut client_b, Duration::from_secs(2))); @@ -968,7 +730,7 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() { .to_string(); // Input from client A should update shared state visible to client B. - send_client_input(&mut client_a, b"echo SHARED_VIEW\n"); + pane_send_text(&api_socket, &pane_id, "echo SHARED_VIEW\n"); assert!( wait_for_frame(&mut client_b, Duration::from_secs(2)), "client B should receive update from client A" @@ -982,19 +744,24 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() { // Detach client A; client B should keep working. send_client_detach(&mut client_a); + assert!( + support::wait_for_disconnect(&mut client_a, Duration::from_secs(2)) + .expect("wait for client A detach"), + "client A should disconnect before the remaining-client assertion" + ); drop(client_a); - send_client_input(&mut client_b, b"echo AFTER_A_DETACH\n"); - assert!( - wait_for_frame(&mut client_b, Duration::from_secs(2)), - "remaining client should still receive frames after other client detaches" - ); + pane_send_text(&api_socket, &pane_id, "echo AFTER_A_DETACH\n"); assert!(pane_read_recent_contains( &api_socket, &pane_id, "AFTER_A_DETACH", Duration::from_secs(5) )); + assert!( + wait_for_frame(&mut client_b, Duration::from_secs(2)), + "remaining client should still receive frames after other client detaches" + ); let ping = ping_socket(&api_socket); assert!( @@ -1123,7 +890,8 @@ fn cross_area_server_kill_then_restart_and_reconnect() { let mut reconnect_client = UnixStream::connect(&client_socket).expect("new client should connect after restart"); - client_handshake(&mut reconnect_client, CURRENT_PROTOCOL, 80, 24); + client_shell_handshake(&mut reconnect_client, CURRENT_PROTOCOL, 80, 24) + .expect("shell handshake"); assert!( wait_for_frame(&mut reconnect_client, Duration::from_secs(5)), "new client should receive frame after restart" diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs index 4461a17b..f570e979 100644 --- a/tests/detach_reattach.rs +++ b/tests/detach_reattach.rs @@ -1,7 +1,7 @@ //! Integration tests for detach/reattach flow. //! -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Write}; @@ -14,9 +14,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use serde_json::Value; use support::{ - cleanup_test_base, client_handshake, drain_messages, read_server_message, register_runtime_dir, - register_spawned_herdr_pid, send_detach, send_input, unregister_spawned_herdr_pid, - wait_for_disconnect, wait_for_message_variant, wait_for_socket, wait_until, CURRENT_PROTOCOL, + cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir, + register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid, wait_for_disconnect, + wait_for_socket, wait_until, CURRENT_PROTOCOL, }; const CUSTOM_HEADLESS_SIZE_CONFIG: &str = r#"onboarding = false @@ -279,72 +279,6 @@ fn first_pane_id(response: &Value) -> String { // Tests // --------------------------------------------------------------------------- -#[test] -fn navigate_q_detaches_client_and_server_persists() { - // In persistence mode, navigate-mode q detaches the client and the server persists. - // Flow: - // 1. Start server - // 2. Connect client, handshake - // 3. Send prefix key (Ctrl+B) then 'q' - // 4. Verify server is still alive via API ping - // 5. Verify the client connection is closed - let _lock = test_lock(); - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let api_socket = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - - let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_socket(&client_socket, Duration::from_secs(10)); - - // Connect and handshake. - let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); - assert_eq!(version, CURRENT_PROTOCOL); - assert!(error.is_none(), "{:?}", error); - - // Drain initial frames. - drain_messages(&mut stream); - - // Send prefix key (Ctrl+B = 0x02) then 'q' (quit/detach in persistence mode). - send_input(&mut stream, &[0x02]).expect("send prefix"); - - // Drain any frames generated by entering navigate mode. - drain_messages(&mut stream); - - send_input(&mut stream, b"q").expect("send detach key"); - - assert!( - wait_until(Duration::from_secs(2), Duration::from_millis(25), || { - ping_socket(&api_socket).contains("pong") - }), - "server should still respond to ping after client detach" - ); - - // Verify server is still alive and responsive. - let response = ping_socket(&api_socket); - assert!( - response.contains("pong"), - "server should still respond to ping after client detach: {response}" - ); - - // The client should receive a ServerShutdown with reason "detached" - // shortly after the quit/detach key. There may be some frames in - // between from the mode change, so we read multiple messages. - let got_shutdown = wait_for_message_variant(&mut stream, Duration::from_secs(2), 2) - .expect("wait for shutdown message") - || wait_for_disconnect(&mut stream, Duration::from_secs(1)).expect("wait for disconnect"); - assert!( - got_shutdown, - "client should receive ServerShutdown after quit/detach key" - ); - - cleanup_spawned_herdr(spawned, base); -} - #[test] fn explicit_detach_message_causes_clean_disconnect() { // Client sends ClientMessage::Detach @@ -363,8 +297,8 @@ fn explicit_detach_message_causes_clean_disconnect() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); @@ -422,7 +356,7 @@ fn reattach_after_detach_shows_current_state() { // --- Client A --- let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); - let (version, error) = client_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); @@ -461,7 +395,7 @@ fn reattach_after_detach_shows_current_state() { // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); - let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!( @@ -470,31 +404,10 @@ fn reattach_after_detach_shows_current_state() { error ); - // Client B should receive a frame with the current state, + // Client B receives the client-owned shell projection. // including the workspace created while client A was attached. - stream_b.set_nonblocking(false).unwrap(); - stream_b - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - - let mut received_frame = false; - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - match read_server_message(&mut stream_b) { - Ok((variant, _payload)) => { - if variant == 1 { - // ServerMessage::Frame - received_frame = true; - break; - } - } - Err(_) => break, - } - } - assert!( - received_frame, - "reattached client should receive a Frame with current state" - ); + support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5)) + .expect("client shell bootstrap"); // Verify the workspace still exists via API. let mut list_stream = UnixStream::connect(&api_socket).expect("connect to API"); @@ -541,19 +454,29 @@ fn processes_survive_during_and_after_detach() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Drain initial frames. drain_messages(&mut stream); - // Send input to the pane — the fresh server should have at least one - // pane with a shell running. - send_input(&mut stream, b"echo SURVIVED_DETACH\n").expect("send echo command"); + // Drive the pane through the JSON API; the shell transport carries only + // client-composed UI state, not legacy raw shell input. + let created = workspace_create(&api_socket, "process-survival"); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .expect("root pane id") + .to_string(); + pane_send_text(&api_socket, &pane_id, "echo SURVIVED_DETACH\n"); + assert!(wait_until( + Duration::from_secs(5), + Duration::from_millis(50), + || pane_read_recent_text(&api_socket, &pane_id).contains("SURVIVED_DETACH") + )); - // Drain any frames generated by the input. + // Drain any shell projection updates. drain_messages(&mut stream); // Detach the client via explicit Detach message. @@ -580,32 +503,14 @@ fn processes_survive_during_and_after_detach() { // Reattach — verify we can connect and receive a frame. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); - let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("reattach handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Verify the reattached client receives a frame. - stream_b - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - let mut received_frame = false; - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - match read_server_message(&mut stream_b) { - Ok((variant, _)) => { - if variant == 1 { - received_frame = true; - break; - } - } - Err(_) => break, - } - } - assert!( - received_frame, - "reattached client should receive a Frame showing current state" - ); + support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5)) + .expect("client shell bootstrap"); cleanup_spawned_herdr(spawned, base); } @@ -629,8 +534,8 @@ fn server_persists_after_client_connection_drop() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); @@ -656,7 +561,7 @@ fn server_persists_after_client_connection_drop() { // Reattach — verify we can connect and handshake again. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); - let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("reattach handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "reattach should succeed: {:?}", error); @@ -723,11 +628,12 @@ fn pane_created_after_detach_uses_configured_headless_size() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut stream = UnixStream::connect(&client_socket).expect("client should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 160, 50).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 160, 50) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{error:?}"); - drain_messages(&mut stream); + support::wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)) + .expect("client shell bootstrap"); let first = workspace_create(&api_socket, "attached-size"); let first_pane_id = first["result"]["root_pane"]["pane_id"] @@ -787,8 +693,8 @@ fn detached_output_preserves_last_attached_pty_size() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut stream = UnixStream::connect(&client_socket).expect("client should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 120, 40).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 120, 40) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); drain_messages(&mut stream); @@ -856,7 +762,7 @@ fn output_accumulated_while_detached_visible_on_reattach() { // Connect and handshake client A. let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); - let (version, error) = client_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); @@ -914,44 +820,14 @@ fn output_accumulated_while_detached_visible_on_reattach() { // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); - let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("reattach handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Client B should receive a frame with the current state. - stream_b - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - let mut received_frame = false; - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - match read_server_message(&mut stream_b) { - Ok((variant, _)) => { - if variant == 1 { - received_frame = true; - break; - } - } - Err(_) => break, - } - } - assert!( - received_frame, - "reattached client should receive a Frame showing current state" - ); - - // Verify the pane content via API includes the output sent while detached. - let read_response = pane_read_recent(&api_socket, &pane_id); - - // The pane output should contain the text sent while detached. - assert!( - read_response["result"]["read"]["text"] - .as_str() - .unwrap_or_default() - .contains("DURING_DETACH"), - "pane should contain output produced while detached: {read_response}" - ); + support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5)) + .expect("client shell bootstrap"); cleanup_spawned_herdr(spawned, base); } diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index 26797af7..d58fb301 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -1,4 +1,4 @@ -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; @@ -12,10 +12,10 @@ use std::time::{Duration, Instant}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use support::{ - cleanup_test_base, client_handshake, client_shell_handshake, register_runtime_dir, - register_spawned_herdr_pid, send_input, unregister_spawned_herdr_pid, - wait_for_client_shell_bootstrap, wait_for_message_variant, wait_for_socket, - SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, + cleanup_test_base, client_shell_handshake, register_runtime_dir, register_spawned_herdr_pid, + send_client_shell_shift_enter, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap, + wait_for_message_variant, wait_for_socket, SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, + SERVER_MESSAGE_SERVER_SHUTDOWN, }; struct SpawnedHerdr { @@ -871,7 +871,7 @@ fn live_handoff_preserves_pane_process_io() { .unwrap() as u32; let mut client_stream = UnixStream::connect(&client_socket).unwrap(); let (server_protocol, error) = - client_shell_handshake(&mut client_stream, protocol, 80, 24, 54, 23).unwrap(); + client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap(); assert_eq!(server_protocol, protocol); assert!(error.is_none(), "client shell handshake failed: {error:?}"); assert!( @@ -900,7 +900,12 @@ fn live_handoff_preserves_pane_process_io() { )); drop(spawned); assert!( - wait_for_message_variant(&mut client_stream, Duration::from_secs(5), 4).unwrap(), + wait_for_message_variant( + &mut client_stream, + Duration::from_secs(5), + SERVER_MESSAGE_SERVER_SHUTDOWN, + ) + .unwrap(), "connected client shell should receive live-handoff shutdown" ); thread::sleep(Duration::from_millis(300)); @@ -949,7 +954,7 @@ fn live_handoff_preserves_pane_process_io() { let mut reattached_shell = UnixStream::connect(&client_socket).unwrap(); let (server_protocol, error) = - client_shell_handshake(&mut reattached_shell, protocol, 80, 24, 54, 23).unwrap(); + client_shell_handshake(&mut reattached_shell, protocol, 54, 23).unwrap(); assert_eq!(server_protocol, protocol); assert!(error.is_none(), "reattached client shell failed: {error:?}"); wait_for_client_shell_bootstrap(&mut reattached_shell, Duration::from_secs(5)) @@ -1040,10 +1045,13 @@ pathlib.Path({received:?}).write_text(data.hex()) wait_for_socket(&client_socket, Duration::from_secs(5)); let mut client_stream = UnixStream::connect(&client_socket).unwrap(); - let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap(); + let (server_protocol, error) = + client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap(); assert_eq!(server_protocol, protocol); - assert!(error.is_none(), "client handshake failed: {error:?}"); - send_input(&mut client_stream, b"\x1b[13;2u").unwrap(); + assert!(error.is_none(), "client shell handshake failed: {error:?}"); + wait_for_client_shell_bootstrap(&mut client_stream, Duration::from_secs(5)) + .expect("client shell should receive restored state before sending input"); + send_client_shell_shift_enter(&mut client_stream, &pane_id).unwrap(); wait_for_file_contains(&received_marker, "1b5b31333b3275", Duration::from_secs(5)); @@ -1131,10 +1139,13 @@ pathlib.Path({received:?}).write_text(data.hex()) wait_for_socket(&client_socket, Duration::from_secs(5)); let mut client_stream = UnixStream::connect(&client_socket).unwrap(); - let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap(); + let (server_protocol, error) = + client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap(); assert_eq!(server_protocol, protocol); - assert!(error.is_none(), "client handshake failed: {error:?}"); - send_input(&mut client_stream, b"\x1b[13;2u").unwrap(); + assert!(error.is_none(), "client shell handshake failed: {error:?}"); + wait_for_client_shell_bootstrap(&mut client_stream, Duration::from_secs(5)) + .expect("client shell should receive restored state before sending input"); + send_client_shell_shift_enter(&mut client_stream, &pane_id).unwrap(); wait_for_file_contains( &received_marker, diff --git a/tests/multi_client.rs b/tests/multi_client.rs index b8fa1a64..cdea7012 100644 --- a/tests/multi_client.rs +++ b/tests/multi_client.rs @@ -1,10 +1,9 @@ -//! Integration tests for multi-client server behavior. +//! Gate B integration tests for the ClientShell protocol. -mod support; +pub mod support; -use std::collections::VecDeque; use std::fs; -use std::io::{self, BufRead, BufReader, Read, Write}; +use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -12,22 +11,21 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -use serde::Deserialize; use serde_json::Value; use support::{ - cleanup_test_base, client_shell_handshake as support_client_shell_handshake, - drain_messages as drain_shell_messages, register_runtime_dir, register_spawned_herdr_pid, - unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap, wait_for_message_variant, - CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE, + cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir, + register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid, + wait_for_client_shell_bootstrap, wait_for_message_variant, CURRENT_PROTOCOL, + SERVER_MESSAGE_PANE_SURFACE, }; fn unique_test_dir() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); + .unwrap() + .as_nanos(); PathBuf::from(format!( - "/tmp/herdr-multi-client-test-{}-{nanos}", + "/tmp/herdr-multi-client-{}-{nanos}", std::process::id() )) } @@ -41,45 +39,26 @@ impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); let _ = self.child.kill(); - if let Some(pid) = pid { let deadline = Instant::now() + Duration::from_secs(2); while Instant::now() < deadline { let mut status = 0; - let result = - unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) }; - if result == pid as libc::pid_t || result == -1 { + let done = unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) }; + if done == pid as libc::pid_t || done == -1 { break; } thread::sleep(Duration::from_millis(20)); } - unregister_spawned_herdr_pid(Some(pid)); } } } -fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) { - drop(spawned); - cleanup_test_base(&base); -} - -fn wait_for_child_exit(child: &mut Box) { - let _ = child.kill(); - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - if child.try_wait().ok().flatten().is_some() { - return; - } - thread::sleep(Duration::from_millis(25)); - } -} - fn test_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) + .unwrap_or_else(|p| p.into_inner()) } fn wait_for_socket(path: &Path, timeout: Duration) { @@ -96,24 +75,19 @@ fn wait_for_socket(path: &Path, timeout: Duration) { fn wait_for_file(path: &Path, timeout: Duration) { let deadline = Instant::now() + timeout; while Instant::now() < deadline { - if path.exists() && UnixStream::connect(path).is_ok() { + if path.exists() { return; } thread::sleep(Duration::from_millis(25)); } - panic!("socket did not accept connections at {}", path.display()); + panic!("socket did not appear at {}", path.display()); } -fn spawn_server(config_home: &Path, runtime_dir: &Path, api_socket_path: &Path) -> SpawnedHerdr { - fs::create_dir_all(config_home.join("herdr")).unwrap(); - fs::create_dir_all(runtime_dir).unwrap(); - register_runtime_dir(runtime_dir); - fs::write( - config_home.join("herdr/config.toml"), - "onboarding = false\n", - ) - .unwrap(); - +fn spawn_server(config: &Path, runtime: &Path, api: &Path) -> SpawnedHerdr { + fs::create_dir_all(config.join("herdr")).unwrap(); + fs::create_dir_all(runtime).unwrap(); + register_runtime_dir(runtime); + fs::write(config.join("herdr/config.toml"), "onboarding = false\n").unwrap(); let pair = native_pty_system() .openpty(PtySize { rows: 24, @@ -122,32 +96,25 @@ fn spawn_server(config_home: &Path, runtime_dir: &Path, api_socket_path: &Path) pixel_height: 0, }) .unwrap(); - let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); cmd.arg("server"); - cmd.env("XDG_CONFIG_HOME", config_home); - cmd.env("XDG_RUNTIME_DIR", runtime_dir); - cmd.env("HERDR_SOCKET_PATH", api_socket_path); + cmd.env("XDG_CONFIG_HOME", config); + cmd.env("XDG_RUNTIME_DIR", runtime); + cmd.env("HERDR_SOCKET_PATH", api); cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); cmd.env("SHELL", "/bin/sh"); cmd.env_remove("HERDR_ENV"); - let child = pair.slave.spawn_command(cmd).unwrap(); register_spawned_herdr_pid(child.process_id()); drop(pair.slave); - SpawnedHerdr { _master: pair.master, child, } } -fn spawn_client_process( - config_home: &Path, - runtime_dir: &Path, - api_socket_path: &Path, -) -> SpawnedHerdr { - register_runtime_dir(runtime_dir); +fn spawn_client(config: &Path, runtime: &Path, api: &Path) -> SpawnedHerdr { + register_runtime_dir(runtime); let pair = native_pty_system() .openpty(PtySize { rows: 24, @@ -156,177 +123,86 @@ fn spawn_client_process( pixel_height: 0, }) .unwrap(); - let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); cmd.arg("client"); cmd.env("HERDR_DISABLE_SOUND", "1"); - cmd.env("XDG_CONFIG_HOME", config_home); - cmd.env("XDG_RUNTIME_DIR", runtime_dir); - cmd.env("HERDR_SOCKET_PATH", api_socket_path); + cmd.env("XDG_CONFIG_HOME", config); + cmd.env("XDG_RUNTIME_DIR", runtime); + cmd.env("HERDR_SOCKET_PATH", api); cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); cmd.env("SHELL", "/bin/sh"); cmd.env_remove("HERDR_ENV"); - let child = pair.slave.spawn_command(cmd).unwrap(); register_spawned_herdr_pid(child.process_id()); drop(pair.slave); - SpawnedHerdr { _master: pair.master, child, } } -fn server_log_path(config_home: &Path) -> PathBuf { - let app_dir = if cfg!(debug_assertions) { - "herdr-dev" - } else { - "herdr" - }; - config_home.join(app_dir).join("herdr-server.log") +fn cleanup(server: SpawnedHerdr, base: PathBuf) { + drop(server); + cleanup_test_base(&base); } -fn count_log_occurrences(path: &Path, needle: &str) -> usize { - fs::read_to_string(path) - .ok() - .map(|text| text.lines().filter(|line| line.contains(needle)).count()) - .unwrap_or(0) -} - -fn log_tail(path: &Path, lines: usize) -> String { - let Ok(text) = fs::read_to_string(path) else { - return format!("could not read {}", path.display()); - }; - let mut tail = VecDeque::with_capacity(lines); - for line in text.lines() { - if tail.len() == lines { - tail.pop_front(); - } - tail.push_back(line.to_string()); - } - tail.into_iter().collect::>().join("\n") -} - -fn wait_for_log_occurrence_count( - path: &Path, - needle: &str, - min_count: usize, - timeout: Duration, -) -> bool { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - if count_log_occurrences(path, needle) >= min_count { - return true; - } - thread::sleep(Duration::from_millis(40)); - } - false -} - -fn ping_socket(socket_path: &Path) -> String { - let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket"); - writeln!( - stream, - "{{\"id\":\"ping\",\"method\":\"ping\",\"params\":{{}}}}" - ) - .unwrap(); - - let mut reader = BufReader::new(stream); - let mut response = String::new(); - reader.read_line(&mut response).unwrap(); - response.trim().to_string() -} - -fn send_json_request(socket_path: &Path, request: &str) -> Value { - let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket"); +fn api_request(socket: &Path, request: &str) -> Value { + let mut stream = UnixStream::connect(socket).unwrap(); writeln!(stream, "{request}").unwrap(); - - let mut reader = BufReader::new(stream); let mut response = String::new(); - reader.read_line(&mut response).unwrap(); - - serde_json::from_str(&response).expect("response should be valid JSON") + BufReader::new(stream).read_line(&mut response).unwrap(); + serde_json::from_str(&response).unwrap() } -fn create_workspace_and_root_pane(socket_path: &Path, label: &str) -> (String, String) { - let response = send_json_request( - socket_path, - &format!( - "{{\"id\":\"ws_create\",\"method\":\"workspace.create\",\"params\":{{\"label\":\"{label}\"}}}}" - ), +fn create_pane(socket: &Path, label: &str) -> String { + let result = api_request( + socket, + &format!(r#"{{"id":"create","method":"workspace.create","params":{{"label":"{label}"}}}}"#), ); - - if response.get("error").is_some() { - panic!("workspace.create failed: {response}"); - } - - let workspace_id = response - .pointer("/result/workspace/workspace_id") - .and_then(Value::as_str) - .expect("workspace.create should return workspace id") - .to_string(); - - let pane_id = response + assert!( + result.get("error").is_none(), + "workspace.create failed: {result}" + ); + result .pointer("/result/root_pane/pane_id") - .and_then(Value::as_str) - .expect("workspace.create should return root pane id") - .to_string(); - - (workspace_id, pane_id) + .unwrap() + .as_str() + .unwrap() + .into() } -fn report_idle_agent(socket_path: &Path, pane_id: &str) { - let response = send_json_request( - socket_path, +fn pane_input(socket: &Path, pane: &str, text: &str) { + let escaped = text.replace('"', "\\\""); + let result = api_request( + socket, &format!( - r#"{{"id":"report_agent","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"idle","source":"multi-client-test"}}}}"# + r#"{{"id":"input","method":"pane.send_input","params":{{"pane_id":"{pane}","text":"{escaped}","keys":["Enter"]}}}}"# ), ); assert!( - response.get("error").is_none(), - "pane.report_agent should succeed: {response}" + result.get("error").is_none(), + "pane.send_input failed: {result}" ); } -fn pane_send_input(socket_path: &Path, pane_id: &str, text: &str) { - let request = format!( - "{{\"id\":\"send_input\",\"method\":\"pane.send_input\",\"params\":{{\"pane_id\":\"{pane_id}\",\"text\":\"{}\",\"keys\":[\"Enter\"]}}}}", - text.replace('"', "\\\"") - ); - let response = send_json_request(socket_path, &request); - if response.get("error").is_some() { - panic!("pane.send_input failed: {response}"); - } -} - -fn pane_read_recent(socket_path: &Path, pane_id: &str, lines: usize) -> String { - let response = send_json_request( - socket_path, +fn pane_text(socket: &Path, pane: &str) -> String { + let result = api_request( + socket, &format!( - "{{\"id\":\"pane_read\",\"method\":\"pane.read\",\"params\":{{\"pane_id\":\"{pane_id}\",\"source\":\"recent\",\"lines\":{lines}}}}}" + r#"{{"id":"read","method":"pane.read","params":{{"pane_id":"{pane}","source":"recent","lines":200}}}}"# ), ); - - if response.get("error").is_some() { - panic!("pane.read failed: {response}"); - } - - response + result .pointer("/result/read/text") .and_then(Value::as_str) .unwrap_or_default() - .to_string() + .into() } -fn pane_read_recent_contains( - socket_path: &Path, - pane_id: &str, - needle: &str, - timeout: Duration, -) -> bool { +fn pane_contains(socket: &Path, pane: &str, needle: &str, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; while Instant::now() < deadline { - if pane_read_recent(socket_path, pane_id, 200).contains(needle) { + if pane_text(socket, pane).contains(needle) { return true; } thread::sleep(Duration::from_millis(50)); @@ -334,930 +210,170 @@ fn pane_read_recent_contains( false } -fn parse_size_after_marker(text: &str, marker: &str) -> Option<(u16, u16)> { - let mut seen_marker = false; - for line in text.lines() { - if !seen_marker { - if line.contains(marker) { - seen_marker = true; - } - continue; - } - - let mut parts = line.split_whitespace(); - let Some(rows_raw) = parts.next() else { - continue; - }; - let Some(cols_raw) = parts.next() else { - continue; - }; - - let Ok(rows) = rows_raw.parse::() else { - continue; - }; - let Ok(cols) = cols_raw.parse::() else { - continue; - }; - - return Some((rows, cols)); - } - - None +fn shell(socket: &Path, cols: u16, rows: u16) -> UnixStream { + let mut stream = UnixStream::connect(socket).unwrap(); + let (version, error) = + client_shell_handshake(&mut stream, CURRENT_PROTOCOL, cols, rows).unwrap(); + assert_eq!(version, CURRENT_PROTOCOL); + assert!(error.is_none(), "ClientShell handshake failed: {error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)).unwrap(); + stream } -fn try_read_pane_tty_size( - socket_path: &Path, - pane_id: &str, - timeout: Duration, -) -> Option<(u16, u16)> { +fn tty_size(socket: &Path, pane: &str, timeout: Duration) -> (u16, u16) { let marker = format!( - "SIZE_MARKER_{}_{}", - std::process::id(), + "SIZE_{}", SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) + .unwrap() + .as_nanos() ); - - pane_send_input(socket_path, pane_id, &format!("echo {marker}; stty size")); - + pane_input(socket, pane, &format!("echo {marker}; stty size")); let deadline = Instant::now() + timeout; while Instant::now() < deadline { - let text = pane_read_recent(socket_path, pane_id, 200); - if let Some(size) = parse_size_after_marker(&text, &marker) { - return Some(size); + let mut found = false; + for line in pane_text(socket, pane).lines() { + if line.contains(&marker) { + found = true; + continue; + } + if found { + let mut words = line.split_whitespace(); + if let (Some(r), Some(c)) = (words.next(), words.next()) { + if let (Ok(r), Ok(c)) = (r.parse(), c.parse()) { + return (r, c); + } + } + } } thread::sleep(Duration::from_millis(50)); } - - None -} - -fn read_pane_tty_size(socket_path: &Path, pane_id: &str, timeout: Duration) -> (u16, u16) { - if let Some(size) = try_read_pane_tty_size(socket_path, pane_id, timeout) { - return size; - } - - let snapshot = pane_read_recent(socket_path, pane_id, 200); - panic!( - "did not observe tty size after marker. pane output:\n{}", - snapshot - ); -} - -// --------------------------------------------------------------------------- -// Minimal bincode v2 varint helpers for protocol tests -// --------------------------------------------------------------------------- - -fn encode_varint_u32(v: u32) -> Vec { - if v < 251 { - vec![v as u8] - } else if v < 65536 { - let mut buf = vec![251u8]; - buf.extend_from_slice(&(v as u16).to_le_bytes()); - buf - } else { - let mut buf = vec![252u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - -fn encode_varint_u16(v: u16) -> Vec { - if v < 251 { - vec![v as u8] - } else { - let mut buf = vec![251u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - -fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec { - let mut buf = encode_varint_u32(variant_idx); - for field in fields { - buf.extend_from_slice(field); - } - buf -} - -fn frame_message(payload: &[u8]) -> Vec { - let len = payload.len() as u32; - let mut framed = len.to_le_bytes().to_vec(); - framed.extend_from_slice(payload); - framed -} - -fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> { - if offset >= payload.len() { - return Err("payload too short for varint".into()); - } - let first_byte = payload[offset]; - match first_byte { - 0..=250 => Ok((first_byte as u32, 1)), - 251 => { - if offset + 3 > payload.len() { - return Err("payload too short for u16 varint".into()); - } - let v = u16::from_le_bytes( - payload[offset + 1..offset + 3] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v as u32, 3)) - } - 252 => { - if offset + 5 > payload.len() { - return Err("payload too short for u32 varint".into()); - } - let v = u32::from_le_bytes( - payload[offset + 1..offset + 5] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v, 5)) - } - _ => Err(format!("unsupported varint tag: {first_byte}")), - } -} - -fn is_timeout(err: &io::Error) -> bool { - matches!( - err.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) -} - -fn read_server_variant(stream: &mut UnixStream, timeout: Duration) -> io::Result { - stream.set_read_timeout(Some(timeout))?; - - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; - if len == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "zero-length payload", - )); - } - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload)?; - - let (variant, _consumed) = decode_varint_u32(&payload, 0) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - Ok(variant) -} - -fn client_handshake( - stream: &mut UnixStream, - version: u32, - cols: u16, - rows: u16, -) -> Result<(), String> { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .map_err(|e| e.to_string())?; - - // ClientMessage::Hello = variant 0 - let hello_payload = encode_varint_enum( - 0, - &[ - &encode_varint_u32(version), - &encode_varint_u16(cols), - &encode_varint_u16(rows), - &encode_varint_u32(8), // cell_width_px - &encode_varint_u32(16), // cell_height_px - &encode_varint_u32(0), // RenderEncoding::SemanticFrame - &encode_varint_u32(0), // ClientKeybindings::Server - &encode_varint_u32(0), // ClientLaunchMode::App - ], - ); - stream - .write_all(&frame_message(&hello_payload)) - .map_err(|e| e.to_string())?; - stream.flush().map_err(|e| e.to_string())?; - - // Read ServerMessage::Welcome = variant 0 - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?; - let len = u32::from_le_bytes(len_buf) as usize; - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload).map_err(|e| e.to_string())?; - - let mut offset = 0; - let (variant, consumed) = decode_varint_u32(&payload, offset)?; - offset += consumed; - if variant != 0 { - return Err(format!("expected Welcome variant 0, got {variant}")); - } - - let (_server_version, consumed) = decode_varint_u32(&payload, offset)?; - offset += consumed; - - let (_encoding, consumed) = decode_varint_u32(&payload, offset)?; - offset += consumed; - - if offset >= payload.len() { - return Err("payload too short for Welcome.error option tag".into()); - } - let option_tag = payload[offset]; - offset += 1; - - if option_tag == 1 { - let (str_len, consumed) = decode_varint_u32(&payload, offset)?; - offset += consumed; - let str_len = str_len as usize; - if offset + str_len > payload.len() { - return Err("payload too short for welcome error string".into()); - } - let err = String::from_utf8(payload[offset..offset + str_len].to_vec()) - .map_err(|e| e.to_string())?; - return Err(format!("handshake rejected: {err}")); - } - - Ok(()) -} - -fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream { - let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket"); - client_handshake(&mut stream, CURRENT_PROTOCOL, cols, rows).expect("handshake should succeed"); - stream -} - -fn send_client_input(stream: &mut UnixStream, data: &[u8]) { - // ClientMessage::Input = variant 1 - let payload = { - let mut buf = encode_varint_u32(1); - buf.extend_from_slice(&encode_varint_u32(data.len() as u32)); - buf.extend_from_slice(data); - buf - }; - stream.write_all(&frame_message(&payload)).unwrap(); - stream.flush().unwrap(); -} - -fn send_client_detach(stream: &mut UnixStream) { - // ClientMessage::Detach = variant 4 - let payload = encode_varint_u32(4); - stream.write_all(&frame_message(&payload)).unwrap(); - stream.flush().unwrap(); -} - -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct FrameWire { - cells: Vec, - width: u16, - height: u16, - cursor: Option, - hyperlinks: Vec, - graphics: Vec, -} - -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct CellWire { - symbol: String, - fg: u32, - bg: u32, - modifier: u16, - skip: bool, - hyperlink: Option, -} - -#[derive(Debug, Deserialize)] -struct CursorWire { - x: u16, - y: u16, - visible: bool, - shape: u8, -} - -fn decode_frame_payload(payload: &[u8]) -> io::Result { - bincode::serde::decode_from_slice(payload, bincode::config::standard()) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string())) - .and_then(|(frame, consumed): (FrameWire, usize)| { - if consumed != payload.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "frame payload had trailing bytes: consumed={}, len={}", - consumed, - payload.len() - ), - )); - } - Ok(frame) - }) -} - -fn read_server_message_payload( - stream: &mut UnixStream, - timeout: Duration, -) -> io::Result<(u32, Vec)> { - stream.set_read_timeout(Some(timeout))?; - - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; - if len == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "zero-length payload", - )); - } - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload)?; - - let (variant, consumed) = decode_varint_u32(&payload, 0) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - Ok((variant, payload[consumed..].to_vec())) -} - -fn drain_server_messages(stream: &mut UnixStream, max_drain: Duration) { - let deadline = Instant::now() + max_drain; - while Instant::now() < deadline { - match read_server_variant(stream, Duration::from_millis(50)) { - Ok(_) => {} - Err(err) if is_timeout(&err) => break, - Err(_) => break, - } - } -} - -fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(Instant::now()); - let slice = remaining.min(Duration::from_millis(75)); - match read_server_variant(stream, slice) { - Ok(1) => return true, // ServerMessage::Frame - Ok(_) => {} - Err(err) if is_timeout(&err) => {} - Err(_) => return false, - } - } - false -} - -fn wait_for_frame_matching_with_snapshots( - stream: &mut UnixStream, - timeout: Duration, - predicate: impl Fn(&FrameWire) -> bool, -) -> io::Result<(bool, Vec)> { - let deadline = Instant::now() + timeout; - let mut snapshots = VecDeque::with_capacity(5); - while Instant::now() < deadline { - let slice = deadline - .saturating_duration_since(Instant::now()) - .min(Duration::from_millis(80)); - match read_server_message_payload(stream, slice) { - Ok((1, frame_payload)) => { - let frame = decode_frame_payload(&frame_payload)?; - if snapshots.len() == 5 { - snapshots.pop_front(); - } - snapshots.push_back(frame_text(&frame)); - if predicate(&frame) { - return Ok((true, snapshots.into_iter().collect())); - } - } - Ok((_variant, _payload)) => {} - Err(err) if is_timeout(&err) => {} - Err(err) => return Err(err), - } - } - - Ok((false, snapshots.into_iter().collect())) -} - -fn frame_text(frame: &FrameWire) -> String { - if frame.cells.is_empty() { - return String::new(); - } - - let row_width = frame.width.max(1) as usize; - let mut full_text = String::new(); - - for row in frame.cells.chunks(row_width) { - for cell in row { - let _ = (cell.fg, cell.bg, cell.modifier, cell.skip); - full_text.push_str(&cell.symbol); - } - full_text.push('\n'); - } - - let _ = (frame.height, frame.graphics.len()); - if let Some(cursor) = frame.cursor.as_ref() { - let _ = (cursor.x, cursor.y, cursor.visible, cursor.shape); - } - - full_text -} - -fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool { - frame_text(frame).contains(needle) -} - -fn agent_panel_starts_with(frame: &FrameWire, agent_label: &str) -> bool { - frame_text(frame) - .lines() - .skip_while(|line| !line.contains("agents")) - .skip(1) - .find(|line| line.contains("agent-")) - .is_some_and(|line| line.contains(agent_label)) + panic!("pane did not report tty size: {}", pane_text(socket, pane)); } #[test] -fn legacy_app_and_client_shell_receive_their_own_render_contracts() { +fn effective_size_uses_smallest_foreground_client_and_recovers_on_disconnect() { let _lock = test_lock(); let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let api_socket = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - - let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - let (_workspace_id, pane_id) = - create_workspace_and_root_pane(&api_socket, "mixed-render-contracts"); - - let mut legacy = connect_raw_client(&client_socket, 100, 30); - drain_server_messages(&mut legacy, Duration::from_millis(200)); - - let mut shell = UnixStream::connect(&client_socket).expect("connect client shell"); - let (version, error) = - support_client_shell_handshake(&mut shell, CURRENT_PROTOCOL, 100, 30, 74, 29) - .expect("client shell handshake"); - assert_eq!(version, CURRENT_PROTOCOL); - assert!(error.is_none(), "client shell handshake failed: {error:?}"); - wait_for_client_shell_bootstrap(&mut shell, Duration::from_secs(5)) - .expect("client shell should receive its snapshot before pane-only surface"); - - drain_server_messages(&mut legacy, Duration::from_millis(200)); - drain_shell_messages(&mut shell); - pane_send_input(&api_socket, &pane_id, "printf 'MIXED_CLIENT_OUTPUT\\n'"); - let (legacy_updated, frames) = - wait_for_frame_matching_with_snapshots(&mut legacy, Duration::from_secs(5), |frame| { - frame_contains_text(frame, "MIXED_CLIENT_OUTPUT") - }) - .expect("read legacy App frames"); + let config = base.join("config"); + let runtime = base.join("runtime"); + let api = runtime.join("herdr.sock"); + let clients = runtime.join("herdr-client.sock"); + let server = spawn_server(&config, &runtime, &api); + wait_for_socket(&api, Duration::from_secs(10)); + wait_for_file(&clients, Duration::from_secs(10)); + let pane = create_pane(&api, "effective-size"); + let mut large = shell(&clients, 120, 40); + let mut small = shell(&clients, 80, 24); + let reduced = tty_size(&api, &pane, Duration::from_secs(5)); assert!( - legacy_updated, - "legacy App client should render pane output; recent frames: {frames:?}" + reduced.0 <= 24 && reduced.1 <= 80, + "small ClientShell should determine effective size: {reduced:?}" ); - assert!( - wait_for_message_variant( - &mut shell, - Duration::from_secs(5), - SERVER_MESSAGE_PANE_SURFACE, - ) - .unwrap(), - "client shell should keep receiving pane surfaces" - ); - - let server_log = server_log_path(&config_home); - let detaches_before = count_log_occurrences(&server_log, "client detached"); - send_client_detach(&mut shell); - assert!( - wait_for_log_occurrence_count( - &server_log, - "client detached", - detaches_before + 1, - Duration::from_secs(5), - ), - "server did not process client shell detach; log tail:\n{}", - log_tail(&server_log, 40) - ); - drop(shell); - assert!( - ping_socket(&api_socket).contains("pong"), - "detaching the client shell must not affect the server or legacy client" - ); - drain_server_messages(&mut legacy, Duration::from_millis(200)); - pane_send_input( - &api_socket, - &pane_id, - "printf 'LEGACY_AFTER_SHELL_DETACH\\n'", - ); - let (legacy_remained_live, frames) = - wait_for_frame_matching_with_snapshots(&mut legacy, Duration::from_secs(5), |frame| { - frame_contains_text(frame, "LEGACY_AFTER_SHELL_DETACH") - }) - .expect("read legacy App frames after shell detach"); - assert!( - legacy_remained_live, - "legacy client should remain live after client shell detaches; recent frames: {frames:?}" - ); - - cleanup_spawned_herdr(spawned, base); -} - -#[test] -fn multi_client_allows_multiple_simultaneous_connections() { - let _lock = test_lock(); - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let api_socket = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let mut client_a = connect_raw_client(&client_socket, 120, 40); - let mut client_b = connect_raw_client(&client_socket, 100, 30); - - assert!( - wait_for_frame(&mut client_a, Duration::from_secs(2)), - "client A should receive frames" - ); - assert!( - wait_for_frame(&mut client_b, Duration::from_secs(2)), - "client B should receive frames" - ); - - let ping = ping_socket(&api_socket); - assert!( - ping.contains("pong"), - "server should remain responsive: {ping}" - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn multi_client_effective_size_shrinks_when_smaller_client_joins() { - let _lock = test_lock(); - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let api_socket = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let (_workspace_id, pane_id) = create_workspace_and_root_pane(&api_socket, "size-shrink"); - - let mut large = connect_raw_client(&client_socket, 120, 40); - assert!(wait_for_frame(&mut large, Duration::from_secs(2))); - let large_only_size = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5)); - - let mut small = connect_raw_client(&client_socket, 80, 24); - assert!(wait_for_frame(&mut small, Duration::from_secs(2))); - - let deadline = Instant::now() + Duration::from_secs(8); - let mut last_seen_size = None; - let mut size_with_small_client = None; - while Instant::now() < deadline { - if let Some(size) = - try_read_pane_tty_size(&api_socket, &pane_id, Duration::from_millis(400)) - { - last_seen_size = Some(size); - if size.0 < large_only_size.0 && size.1 < large_only_size.1 { - size_with_small_client = Some(size); - break; - } - } - thread::sleep(Duration::from_millis(60)); - } - - assert!( - size_with_small_client.is_some(), - "effective pane size should shrink when smaller client joins: before={large_only_size:?}, last_seen={last_seen_size:?}" - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn non_foreground_client_render_preserves_agent_panel_scroll() { - let _lock = test_lock(); - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let api_socket = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - for index in 1..=23 { - let (_, pane_id) = - create_workspace_and_root_pane(&api_socket, &format!("agent-{index:02}")); - report_idle_agent(&api_socket, &pane_id); - } - - let mut setup_client = connect_raw_client(&client_socket, 106, 40); - assert!(wait_for_frame(&mut setup_client, Duration::from_secs(2))); - drain_server_messages(&mut setup_client, Duration::from_millis(250)); - - let wheel_down = b"\x1b[<65;10;30M"; - send_client_input(&mut setup_client, &wheel_down.repeat(20)); - let (reached_bottom, setup_frames) = wait_for_frame_matching_with_snapshots( - &mut setup_client, + send_detach(&mut small).unwrap(); + drop(small); + assert!(wait_for_message_variant( + &mut large, Duration::from_secs(3), - |frame| agent_panel_starts_with(frame, "agent-16"), + SERVER_MESSAGE_PANE_SURFACE ) - .expect("setup frame decoding should succeed"); + .unwrap()); + let restored = tty_size(&api, &pane, Duration::from_secs(5)); assert!( - reached_bottom, - "40-row client should scroll the agent panel to its final page; frames:\n{}", - setup_frames.join("\n--- frame ---\n") + restored.0 > reduced.0 && restored.1 > reduced.1, + "size should recover: {reduced:?} -> {restored:?}" ); - send_client_detach(&mut setup_client); - drop(setup_client); - - let mut tall_background = connect_raw_client(&client_socket, 106, 64); - assert!(wait_for_frame(&mut tall_background, Duration::from_secs(2))); - let mut probe = connect_raw_client(&client_socket, 106, 40); - let (started_at_tall_limit, initial_frames) = - wait_for_frame_matching_with_snapshots(&mut probe, Duration::from_secs(3), |frame| { - agent_panel_starts_with(frame, "agent-10") - }) - .expect("initial probe frame decoding should succeed"); - assert!( - started_at_tall_limit, - "tall client should normalize the shared scroll before the probe attaches; frames:\n{}", - initial_frames.join("\n--- frame ---\n") - ); - drain_server_messages(&mut probe, Duration::from_millis(250)); - - send_client_input(&mut probe, wheel_down); - let (scrolled, probe_frames) = - wait_for_frame_matching_with_snapshots(&mut probe, Duration::from_secs(3), |frame| { - agent_panel_starts_with(frame, "agent-11") - }) - .expect("probe frame decoding should succeed"); - assert!( - scrolled, - "background client projection must not undo the foreground wheel event; frames:\n{}", - probe_frames.join("\n--- frame ---\n") - ); - - cleanup_spawned_herdr(server, base); + cleanup(server, base); } #[test] -fn multi_client_broadcasts_frame_updates_to_all_clients() { +fn api_pane_output_is_fanned_out_as_pane_surfaces() { let _lock = test_lock(); let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let api_socket = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let mut client_a = connect_raw_client(&client_socket, 100, 30); - let mut client_b = connect_raw_client(&client_socket, 100, 30); - - // Ensure we have an active pane that can reflect input changes. - let (_workspace_id, pane_id) = - create_workspace_and_root_pane(&api_socket, "broadcast-client-a-to-b"); - - // Drain initial frames so we measure the frame caused by new input. - drain_server_messages(&mut client_a, Duration::from_millis(300)); - drain_server_messages(&mut client_b, Duration::from_millis(300)); - + let config = base.join("config"); + let runtime = base.join("runtime"); + let api = runtime.join("herdr.sock"); + let clients = runtime.join("herdr-client.sock"); + let server = spawn_server(&config, &runtime, &api); + wait_for_socket(&api, Duration::from_secs(10)); + wait_for_file(&clients, Duration::from_secs(10)); + let pane = create_pane(&api, "fanout"); + let mut a = shell(&clients, 100, 30); + let mut b = shell(&clients, 100, 30); + drain_messages(&mut a); + drain_messages(&mut b); let marker = format!( - "MB{}", + "FANOUT_{}", SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) + .unwrap() + .as_millis() ); + pane_input(&api, &pane, &format!("printf '{marker}\\n'")); + assert!(pane_contains(&api, &pane, &marker, Duration::from_secs(5))); + assert!( + wait_for_message_variant(&mut a, Duration::from_secs(5), SERVER_MESSAGE_PANE_SURFACE) + .unwrap() + ); + assert!( + wait_for_message_variant(&mut b, Duration::from_secs(5), SERVER_MESSAGE_PANE_SURFACE) + .unwrap() + ); + cleanup(server, base); +} - send_client_input(&mut client_a, format!("echo {marker}\n").as_bytes()); - if !pane_read_recent_contains(&api_socket, &pane_id, &marker, Duration::from_secs(5)) { - panic!( - "pane output should include client A marker so broadcast reflects a real state change. pane output:\n{}\nserver log tail:\n{}", - pane_read_recent(&api_socket, &pane_id, 200), - log_tail(&server_log_path(&config_home), 80) - ); +#[test] +fn crashed_client_shell_does_not_affect_survivor() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config = base.join("config"); + let runtime = base.join("runtime"); + let api = runtime.join("herdr.sock"); + let clients = runtime.join("herdr-client.sock"); + let server = spawn_server(&config, &runtime, &api); + wait_for_socket(&api, Duration::from_secs(10)); + wait_for_file(&clients, Duration::from_secs(10)); + let mut survivor = shell(&clients, 100, 30); + let crashed = spawn_client(&config, &runtime, &api); + // Give the supported client process time to complete its ClientShell hello; + // the point of this test is a connected peer dying, not a failed launch. + thread::sleep(Duration::from_secs(1)); + let pid = crashed.child.process_id().unwrap(); + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGKILL); } - let (received, client_b_frames) = - wait_for_frame_matching_with_snapshots(&mut client_b, Duration::from_secs(10), |frame| { - frame_contains_text(frame, &marker) - }) - .expect("frame decoding should succeed"); - - assert!( - received, - "client B should receive a broadcast frame containing client A marker. pane output:\n{}\nclient B frame snapshots:\n{}\nserver log tail:\n{}", - pane_read_recent(&api_socket, &pane_id, 200), - client_b_frames.join("\n--- frame ---\n"), - log_tail(&server_log_path(&config_home), 80) - ); - - cleanup_spawned_herdr(server, base); + drop(crashed); + let response = api_request(&api, r#"{"id":"ping","method":"ping","params":{}}"#); + assert!(response.to_string().contains("pong")); + pane_input(&api, &create_pane(&api, "survivor"), "printf 'survivor\\n'"); + assert!(wait_for_message_variant( + &mut survivor, + Duration::from_secs(5), + SERVER_MESSAGE_PANE_SURFACE + ) + .unwrap()); + cleanup(server, base); } #[test] -fn multi_client_disconnect_recalculates_to_next_smallest() { +fn rapid_client_shell_connect_disconnect_remains_healthy() { let _lock = test_lock(); let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let api_socket = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let (_workspace_id, pane_id) = - create_workspace_and_root_pane(&api_socket, "size-next-smallest"); - - let mut c120 = connect_raw_client(&client_socket, 120, 40); - let mut c100 = connect_raw_client(&client_socket, 100, 30); - let mut c80 = connect_raw_client(&client_socket, 80, 24); - - assert!(wait_for_frame(&mut c120, Duration::from_secs(2))); - assert!(wait_for_frame(&mut c100, Duration::from_secs(2))); - assert!(wait_for_frame(&mut c80, Duration::from_secs(2))); - - let size_with_three = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5)); - - drain_server_messages(&mut c100, Duration::from_millis(250)); - - // Smallest client disconnects; effective size should increase to the next-smallest. - send_client_detach(&mut c80); - drop(c80); - - assert!( - wait_for_frame(&mut c100, Duration::from_secs(2)), - "next-smallest client should receive resized-up frame" - ); - - let deadline = Instant::now() + Duration::from_secs(8); - let mut size_after_smallest_disconnect = None; - while Instant::now() < deadline { - let maybe_size = try_read_pane_tty_size(&api_socket, &pane_id, Duration::from_millis(400)); - if let Some(size) = maybe_size { - if size.0 > size_with_three.0 && size.1 > size_with_three.1 { - size_after_smallest_disconnect = Some(size); - break; - } - } - thread::sleep(Duration::from_millis(60)); - } - - assert!( - size_after_smallest_disconnect.is_some(), - "effective pane size should increase after smallest disconnects: before={:?}, last_seen={:?}", - size_with_three, - try_read_pane_tty_size(&api_socket, &pane_id, Duration::from_millis(300)) - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn multi_client_smallest_leaving_resizes_up_for_remaining_clients() { - let _lock = test_lock(); - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let api_socket = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let (_workspace_id, pane_id) = create_workspace_and_root_pane(&api_socket, "size-resize-up"); - - let mut large = connect_raw_client(&client_socket, 120, 40); - let mut small = connect_raw_client(&client_socket, 80, 24); - - assert!(wait_for_frame(&mut large, Duration::from_secs(2))); - assert!(wait_for_frame(&mut small, Duration::from_secs(2))); - - let size_with_small_client = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5)); - - drain_server_messages(&mut large, Duration::from_millis(250)); - - send_client_detach(&mut small); - drop(small); - - // Remaining client should receive a new (larger) frame. - assert!( - wait_for_frame(&mut large, Duration::from_secs(2)), - "remaining client should receive resized-up frame" - ); - - let size_after_small_leaves = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5)); - - assert!( - size_after_small_leaves.0 > size_with_small_client.0 - && size_after_small_leaves.1 > size_with_small_client.1, - "remaining clients should get larger effective pane size after smallest leaves: before={:?}, after={:?}", - size_with_small_client, - size_after_small_leaves - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn multi_client_client_crash_sigkill_does_not_affect_server() { - let _lock = test_lock(); - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let api_socket = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let mut survivor = connect_raw_client(&client_socket, 100, 30); - assert!(wait_for_frame(&mut survivor, Duration::from_secs(2))); - - let log_path = server_log_path(&config_home); - let connected_before = count_log_occurrences(&log_path, "client connected"); - - let crashing_client = spawn_client_process(&config_home, &runtime_dir, &api_socket); - - let attached_before_kill = wait_for_log_occurrence_count( - &log_path, - "client connected", - connected_before + 1, - Duration::from_secs(8), - ); - assert!( - attached_before_kill, - "thin client must complete handshake/attachment before SIGKILL" - ); - - if let Some(pid) = crashing_client.child.process_id() { - unsafe { - libc::kill(pid as libc::pid_t, libc::SIGKILL); - } - } - let mut crashing_client = crashing_client; - wait_for_child_exit(&mut crashing_client.child); - - let ping = ping_socket(&api_socket); - assert!( - ping.contains("pong"), - "server should stay healthy after SIGKILLed client: {ping}" - ); - - drain_server_messages(&mut survivor, Duration::from_millis(250)); - send_client_input(&mut survivor, b"echo survivor-still-works\n"); - assert!( - wait_for_frame(&mut survivor, Duration::from_secs(2)), - "remaining client should continue receiving frames" - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn multi_client_rapid_connect_disconnect_stress_10_cycles() { - let _lock = test_lock(); - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let api_socket = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - for i in 0..10u16 { - let mut client = connect_raw_client(&client_socket, 80 + i, 24 + (i % 4)); - let _ = wait_for_frame(&mut client, Duration::from_millis(500)); - send_client_detach(&mut client); + let config = base.join("config"); + let runtime = base.join("runtime"); + let api = runtime.join("herdr.sock"); + let clients = runtime.join("herdr-client.sock"); + let server = spawn_server(&config, &runtime, &api); + wait_for_socket(&api, Duration::from_secs(10)); + wait_for_file(&clients, Duration::from_secs(10)); + for i in 0..10 { + let mut client = shell(&clients, 80 + i, 24); + send_detach(&mut client).unwrap(); drop(client); - thread::sleep(Duration::from_millis(40)); } - - let ping = ping_socket(&api_socket); - assert!( - ping.contains("pong"), - "server should remain healthy after rapid connect/disconnect: {ping}" - ); - - let mut final_client = connect_raw_client(&client_socket, 100, 30); - assert!( - wait_for_frame(&mut final_client, Duration::from_secs(2)), - "new client should still connect and receive frames after stress" - ); - - cleanup_spawned_herdr(server, base); + let final_client = shell(&clients, 100, 30); + drop(final_client); + let response = api_request(&api, r#"{"id":"ping","method":"ping","params":{}}"#); + assert!(response.to_string().contains("pong")); + cleanup(server, base); } diff --git a/tests/server_headless.rs b/tests/server_headless.rs index 5abb5ace..f6eb0771 100644 --- a/tests/server_headless.rs +++ b/tests/server_headless.rs @@ -1,6 +1,6 @@ //! Integration tests for headless server mode. -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; @@ -13,7 +13,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use support::{ - cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid, + cleanup_test_base, client_handshake, register_runtime_dir, register_spawned_herdr_pid, unregister_spawned_herdr_pid, CURRENT_PROTOCOL, }; @@ -151,207 +151,6 @@ fn ping_socket(socket_path: &Path) -> String { response.trim().to_string() } -/// Sends a Hello message over the client socket and reads the Welcome response. -/// Uses bincode v2 wire format: [u32LE length][bincode payload] -/// bincode v2 standard config uses VarintEncoding: -/// - Integers < 251 are encoded as a single byte -/// - Enum variant index is encoded as u32 varint -/// - Option discriminant is always a single byte (0=None, 1=Some) -/// - String: length (varint) + UTF-8 bytes -fn client_handshake( - stream: &mut UnixStream, - version: u32, - cols: u16, - rows: u16, -) -> Result<(u32, Option), String> { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .map_err(|e| e.to_string())?; - - // Encode Hello message using bincode v2 varint format. - // ClientMessage::Hello is variant 0. - let hello_payload = encode_varint_enum( - 0, - &[ - &encode_varint_u32(version), - &encode_varint_u16(cols), - &encode_varint_u16(rows), - &encode_varint_u32(8), // cell_width_px - &encode_varint_u32(16), // cell_height_px - &encode_varint_u32(0), // RenderEncoding::SemanticFrame - &encode_varint_u32(0), // ClientKeybindings::Server - &encode_varint_u32(0), // ClientLaunchMode::App - ], - ); - let framed = frame_message(&hello_payload); - stream.write_all(&framed).map_err(|e| e.to_string())?; - stream.flush().map_err(|e| e.to_string())?; - - // Read the framed response. - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?; - let len = u32::from_le_bytes(len_buf) as usize; - - if len > 2 * 1024 * 1024 { - return Err(format!("oversized response: {len}")); - } - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload).map_err(|e| e.to_string())?; - - // Decode Welcome: ServerMessage variant 0 = Welcome { version: u32, error: Option } - decode_welcome(&payload) -} - -/// Encode a varint u32 value according to bincode v2 VarintEncoding. -fn encode_varint_u32(v: u32) -> Vec { - if v < 251 { - vec![v as u8] - } else if v < 65536 { - let mut buf = vec![251u8]; - buf.extend_from_slice(&(v as u16).to_le_bytes()); - buf - } else { - let mut buf = vec![252u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - -/// Encode a varint u16 value. -fn encode_varint_u16(v: u16) -> Vec { - if v < 251 { - vec![v as u8] - } else { - let mut buf = vec![251u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - -/// Encode an enum variant with its fields. -fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec { - let mut buf = encode_varint_u32(variant_idx); - for field in fields { - buf.extend_from_slice(field); - } - buf -} - -/// Frame a message with u32LE length prefix. -fn frame_message(payload: &[u8]) -> Vec { - let len = payload.len() as u32; - let mut framed = len.to_le_bytes().to_vec(); - framed.extend_from_slice(payload); - framed -} - -/// Decode a varint u32 from a byte slice at the given offset. -/// Returns (value, bytes_consumed). -fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> { - if offset >= payload.len() { - return Err("payload too short for varint".into()); - } - let first_byte = payload[offset]; - match first_byte { - 0..=250 => Ok((first_byte as u32, 1)), - 251 => { - if offset + 3 > payload.len() { - return Err("payload too short for u16 varint".into()); - } - let v = u16::from_le_bytes( - payload[offset + 1..offset + 3] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v as u32, 3)) - } - 252 => { - if offset + 5 > payload.len() { - return Err("payload too short for u32 varint".into()); - } - let v = u32::from_le_bytes( - payload[offset + 1..offset + 5] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v, 5)) - } - _ => Err(format!("unsupported varint tag: {first_byte}")), - } -} - -/// Decode a varint u16 from a byte slice at the given offset. -#[allow(dead_code)] -fn decode_varint_u16(payload: &[u8], offset: usize) -> Result<(u16, usize), String> { - if offset >= payload.len() { - return Err("payload too short for varint".into()); - } - let first_byte = payload[offset]; - match first_byte { - 0..=250 => Ok((first_byte as u16, 1)), - 251 => { - if offset + 3 > payload.len() { - return Err("payload too short for u16 varint".into()); - } - let v = u16::from_le_bytes( - payload[offset + 1..offset + 3] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v, 3)) - } - _ => Err(format!("unsupported varint tag for u16: {first_byte}")), - } -} - -/// Decode a ServerMessage::Welcome from bincode v2 payload. -fn decode_welcome(payload: &[u8]) -> Result<(u32, Option), String> { - let mut offset = 0; - - // Variant index (should be 0 for Welcome) - let (variant, consumed) = decode_varint_u32(payload, offset)?; - offset += consumed; - if variant != 0 { - return Err(format!( - "expected Welcome (variant 0), got variant {variant}" - )); - } - - // version: u32 - let (version, consumed) = decode_varint_u32(payload, offset)?; - offset += consumed; - - // encoding: RenderEncoding - let (_encoding, consumed) = decode_varint_u32(payload, offset)?; - offset += consumed; - - // error: Option — discriminant is always 1 byte - if offset >= payload.len() { - return Err("payload too short for Option tag".into()); - } - let option_tag = payload[offset]; - offset += 1; - - let error = if option_tag == 1 { - // Some(String) — length as varint + UTF-8 bytes - let (str_len, consumed) = decode_varint_u32(payload, offset)?; - offset += consumed; - let str_len = str_len as usize; - - if offset + str_len > payload.len() { - return Err("payload too short for string content".into()); - } - let s = String::from_utf8(payload[offset..offset + str_len].to_vec()) - .map_err(|e| e.to_string())?; - Some(s) - } else { - None - }; - - Ok((version, error)) -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 0a687511..df7de7a1 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use std::collections::HashSet; use std::fs; use std::io::{Read, Write}; @@ -16,9 +14,12 @@ static CLEANUP_GUARD: OnceLock = OnceLock::new(); const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1); const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid"; pub const CURRENT_PROTOCOL: u32 = 21; -pub const SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT: u32 = 15; -pub const SERVER_MESSAGE_PANE_SURFACE: u32 = 16; -const CLIENT_MESSAGE_CLIENT_SHELL_HELLO: u32 = 13; +pub const SERVER_MESSAGE_SERVER_SHUTDOWN: u32 = 3; +pub const SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT: u32 = 12; +pub const SERVER_MESSAGE_PANE_SURFACE: u32 = 13; +pub const SERVER_MESSAGE_SEMANTIC_NOTIFICATION: u32 = 14; +const CLIENT_MESSAGE_CLIENT_SHELL_HELLO: u32 = 11; +const CLIENT_MESSAGE_CLIENT_SHELL_PANE_INPUT: u32 = 13; pub fn register_spawned_herdr_pid(pid: Option) { let Some(pid) = pid else { @@ -111,7 +112,7 @@ pub fn wait_for_file(path: &Path, timeout: Duration) { panic!("file did not appear at {}", path.display()); } -pub fn encode_varint_u32(v: u32) -> Vec { +fn encode_varint_u32(v: u32) -> Vec { if v < 251 { vec![v as u8] } else if v < 65536 { @@ -125,7 +126,7 @@ pub fn encode_varint_u32(v: u32) -> Vec { } } -pub fn encode_varint_u16(v: u16) -> Vec { +fn encode_varint_u16(v: u16) -> Vec { if v < 251 { vec![v as u8] } else { @@ -135,14 +136,14 @@ pub fn encode_varint_u16(v: u16) -> Vec { } } -pub fn frame_message(payload: &[u8]) -> Vec { +fn frame_message(payload: &[u8]) -> Vec { let len = payload.len() as u32; let mut framed = len.to_le_bytes().to_vec(); framed.extend_from_slice(payload); framed } -pub fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> { +fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> { if offset >= payload.len() { return Err("payload too short for varint".into()); } @@ -260,9 +261,7 @@ pub fn client_handshake( &encode_varint_u16(rows), &encode_varint_u32(8), // cell_width_px &encode_varint_u32(16), // cell_height_px - &encode_varint_u32(0), // RenderEncoding::SemanticFrame - &encode_varint_u32(0), // ClientKeybindings::Server - &encode_varint_u32(0), // ClientLaunchMode::App + &[0], // pixel_mouse = false ], ); finish_handshake(stream, &hello_payload) @@ -271,8 +270,6 @@ pub fn client_handshake( pub fn client_shell_handshake( stream: &mut UnixStream, version: u32, - cols: u16, - rows: u16, surface_cols: u16, surface_rows: u16, ) -> Result<(u32, Option), String> { @@ -280,16 +277,14 @@ pub fn client_shell_handshake( CLIENT_MESSAGE_CLIENT_SHELL_HELLO, &[ &encode_varint_u32(version), - &encode_varint_u16(cols), - &encode_varint_u16(rows), &encode_varint_u32(8), &encode_varint_u32(16), - &encode_varint_u32(0), &encode_varint_u16(surface_cols), &encode_varint_u16(surface_rows), - &[0], - &[0], + &[0], // pixel mouse disabled + &[0], // direct graphics disabled &[0], // client-owned keybindings + &[0], // mouse capture disabled ], ); finish_handshake(stream, &hello_payload) @@ -317,16 +312,27 @@ pub fn read_server_message(stream: &mut UnixStream) -> Result<(u32, Vec), St Ok((variant, payload[consumed..].to_vec())) } -pub fn send_input(stream: &mut UnixStream, data: &[u8]) -> Result<(), String> { - let mut buf = encode_varint_u32(1); - buf.extend_from_slice(&encode_varint_u32(data.len() as u32)); - buf.extend_from_slice(data); - let framed = frame_message(&buf); +pub fn send_client_shell_shift_enter(stream: &mut UnixStream, pane_id: &str) -> Result<(), String> { + let mut payload = encode_varint_u32(CLIENT_MESSAGE_CLIENT_SHELL_PANE_INPUT); + payload.extend_from_slice(&encode_varint_u32(pane_id.len() as u32)); + payload.extend_from_slice(pane_id.as_bytes()); + payload.extend_from_slice(&encode_varint_u32(1)); // one pane input event + payload.extend_from_slice(&encode_varint_u32(0)); // Key + payload.extend_from_slice(&encode_varint_u32(1)); // Enter + payload.push(1); // Shift + payload.extend_from_slice(&encode_varint_u32(0)); // Press + payload.extend_from_slice(&encode_varint_u16(1)); + payload.push(0); // no shifted codepoint + payload.push(0); // no generated text + payload.push(0); // does not track release + payload.push(0); // no physical key id + stream - .write_all(&framed) - .map_err(|e| format!("write input: {e}"))?; - stream.flush().map_err(|e| format!("flush input: {e}"))?; - Ok(()) + .write_all(&frame_message(&payload)) + .map_err(|e| format!("write client shell key: {e}"))?; + stream + .flush() + .map_err(|e| format!("flush client shell key: {e}")) } pub fn send_detach(stream: &mut UnixStream) -> Result<(), String> {