use std::collections::{HashMap, HashSet, VecDeque}; mod actions; mod agent_sidebar; mod aggregate_navigation; mod workspace_navigation; use workspace_navigation::WorkspaceNavigationTarget; mod composition; mod config; mod context_menu; mod copy_mode; mod endpoint_agent_state; mod endpoint_agents; mod endpoint_navigation; mod endpoint_notices; mod endpoint_sidebar; mod endpoints; pub(super) use endpoints::*; mod global_menu; mod graphics; mod input; mod input_source; mod mobile; mod mouse; mod notification_policy; mod notifications; mod overlay_input; mod preferences; mod render; mod scroll; mod settings; mod state; mod surface_patch; mod word_selection; mod worktrees; use word_selection::ClientWordSelection; pub(in crate::client::shell) use render::sidebar; pub(crate) use state::*; #[cfg(test)] pub(super) use surface_patch::apply_composed_surface_patch; pub(super) use surface_patch::{ClientComposedSurfacePatch, ClientPaneSurfacePatchOutcome}; use crossterm::event::KeyCode; #[cfg(test)] use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use unicode_width::UnicodeWidthStr; use super::endpoint::{ClientEndpointId, ClientEndpointStatus, SavedSshEndpoint}; use crate::app::state::Palette; use crate::config::{ Config, LiveKeybindConfig, SidebarCollapsedModeConfig, SpacesSidebarConfig, TabBarPositionConfig, }; use crate::protocol::{ ClientMessage, ClientMousePosition, ClientPaneInputEvent, ClientShellSnapshot, ClientShellTab, ClientShellWorkspace, ClientSurfaceSize, FrameData, PaneSurfaceFrame, SemanticNotification, SemanticNotificationKind, SemanticNotificationSound, }; #[cfg(test)] use crate::raw_input::RawInputEvent; fn delete_overlay_word(rename: &mut ClientRenameOverlay) { if rename.replace_on_type { rename.input.clear(); rename.replace_on_type = false; return; } while rename.input.chars().last().is_some_and(char::is_whitespace) { rename.input.pop(); } let Some(word) = rename .input .chars() .last() .map(|character| character.is_alphanumeric() || character == '_') else { return; }; while rename.input.chars().last().is_some_and(|character| { !character.is_whitespace() && (character.is_alphanumeric() || character == '_') == word }) { rename.input.pop(); } } fn target_event_message(target: ClientInputTarget, event: ClientPaneInputEvent) -> ClientMessage { match target { ClientInputTarget::Pane(pane_id) => ClientMessage::ClientShellPaneInput { pane_id, events: vec![event], }, ClientInputTarget::Popup(terminal_id) => ClientMessage::ClientShellPopupInput { terminal_id, events: vec![event], }, } } fn push_target_event( target: ClientInputTarget, event: ClientPaneInputEvent, outcome: &mut ClientShellInput, ) { match target { ClientInputTarget::Pane(pane_id) => { if let Some(ClientMessage::ClientShellPaneInput { pane_id: pending_pane, events, }) = outcome.requests.last_mut() { if *pending_pane == pane_id { events.push(event); return; } } outcome.requests.push(target_event_message( ClientInputTarget::Pane(pane_id), event, )); } ClientInputTarget::Popup(terminal_id) => { if let Some(ClientMessage::ClientShellPopupInput { terminal_id: pending_terminal, events, }) = outcome.requests.last_mut() { if *pending_terminal == terminal_id { events.push(event); return; } } outcome.requests.push(target_event_message( ClientInputTarget::Popup(terminal_id), event, )); } } } fn contains(rect: Rect, point: (u16, u16)) -> bool { rect.width > 0 && rect.height > 0 && point.0 >= rect.x && point.0 < rect.right() && point.1 >= rect.y && point.1 < rect.bottom() } fn pane_surface_topology_signature(surface: &PaneSurfaceFrame) -> u64 { const OFFSET: u64 = 0xcbf2_9ce4_8422_2325; const PRIME: u64 = 0x0000_0100_0000_01b3; fn write(hash: &mut u64, bytes: &[u8]) { for byte in bytes { *hash ^= u64::from(*byte); *hash = hash.wrapping_mul(PRIME); } *hash ^= 0xff; *hash = hash.wrapping_mul(PRIME); } let mut pane_ids = surface .panes .iter() .map(|pane| pane.pane_id.as_bytes()) .collect::>(); pane_ids.sort_unstable(); let mut hash = OFFSET; for pane_id in pane_ids { write(&mut hash, pane_id); } let mut splits = surface.splits.iter().collect::>(); splits.sort_by(|left, right| left.path.cmp(&right.path)); for split in splits { write( &mut hash, &[match split.direction { crate::protocol::PaneSurfaceSplitDirection::Horizontal => 0, crate::protocol::PaneSurfaceSplitDirection::Vertical => 1, }], ); write( &mut hash, &split .path .iter() .map(|right| u8::from(*right)) .collect::>(), ); } hash } fn status_icon( status: crate::api::schema::AgentStatus, style: crate::config::StatusIndicatorStyle, ) -> &'static str { use crate::api::schema::AgentStatus; use crate::config::StatusIndicatorStyle; match (style, status) { ( StatusIndicatorStyle::Dots, AgentStatus::Working | AgentStatus::Blocked | AgentStatus::Done, ) => "●", (StatusIndicatorStyle::Dots, AgentStatus::Idle) => "○", (StatusIndicatorStyle::Dots, AgentStatus::Unknown) => "·", (StatusIndicatorStyle::Symbols, AgentStatus::Blocked) => "×", (StatusIndicatorStyle::Symbols, AgentStatus::Working) => "◐", (StatusIndicatorStyle::Symbols, AgentStatus::Done) => "✓", (StatusIndicatorStyle::Symbols, AgentStatus::Idle) => "○", (StatusIndicatorStyle::Symbols, AgentStatus::Unknown) => "·", } } fn status_dot(status: crate::api::schema::AgentStatus) -> &'static str { status_icon(status, crate::config::StatusIndicatorStyle::Dots) } fn status_priority(status: crate::api::schema::AgentStatus) -> u8 { use crate::api::schema::AgentStatus; match status { AgentStatus::Blocked => 4, AgentStatus::Done => 3, AgentStatus::Working => 2, AgentStatus::Idle => 1, AgentStatus::Unknown => 0, } } fn status_text(status: crate::api::schema::AgentStatus) -> &'static str { use crate::api::schema::AgentStatus; match status { AgentStatus::Working => "working", AgentStatus::Blocked => "blocked", AgentStatus::Done => "done", AgentStatus::Idle => "idle", AgentStatus::Unknown => "unknown", } } fn status_color( status: crate::api::schema::AgentStatus, palette: &Palette, ) -> ratatui::style::Color { use crate::api::schema::AgentStatus; match status { AgentStatus::Working => palette.yellow, AgentStatus::Blocked => palette.red, AgentStatus::Done => palette.teal, AgentStatus::Idle => palette.green, AgentStatus::Unknown => palette.overlay0, } } fn panel_contrast_fg(palette: &Palette) -> ratatui::style::Color { match palette.panel_bg { ratatui::style::Color::Reset => palette.surface_dim, color => color, } } fn blit_pane_surface(target: &mut FrameData, source: &FrameData, area: Rect) { let copy_width = source.width.min(area.width); let copy_height = source.height.min(area.height); let hyperlink_base = target.hyperlinks.len() as u32; target.hyperlinks.extend(source.hyperlinks.iter().cloned()); for row in 0..copy_height { for col in 0..copy_width { let source_index = row as usize * source.width as usize + col as usize; let target_x = area.x + col; let target_y = area.y + row; let target_index = target_y as usize * target.width as usize + target_x as usize; let (Some(source_cell), Some(target_cell)) = ( source.cells.get(source_index), target.cells.get_mut(target_index), ) else { continue; }; *target_cell = source_cell.clone(); target_cell.hyperlink = source_cell.hyperlink.and_then(|index| { ((index as usize) < source.hyperlinks.len()).then_some(hyperlink_base + index) }); } } target.cursor = source.cursor.as_ref().and_then(|cursor| { (cursor.x < copy_width && cursor.y < copy_height).then(|| crate::protocol::CursorState { x: area.x + cursor.x, y: area.y + cursor.y, visible: cursor.visible, shape: cursor.shape, }) }); target.graphics.clear(); } #[cfg(test)] mod tests;