aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/shell.rs')
-rw-r--r--src/client/shell.rs287
1 files changed, 287 insertions, 0 deletions
diff --git a/src/client/shell.rs b/src/client/shell.rs
new file mode 100644
index 00000000..9a877744
--- /dev/null
+++ b/src/client/shell.rs
@@ -0,0 +1,287 @@
+use std::collections::{HashMap, HashSet, VecDeque};
+
+mod actions;
+mod agent_sidebar;
+mod composition;
+mod config;
+mod context_menu;
+mod copy_mode;
+mod global_menu;
+mod graphics;
+mod input;
+mod mobile;
+mod mouse;
+mod notifications;
+mod overlay_input;
+mod preferences;
+mod render;
+mod scroll;
+mod settings;
+mod state;
+mod surface_patch;
+mod worktrees;
+
+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 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::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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::<Vec<_>>(),
+ );
+ }
+ 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;