aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/state.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-06 02:27:06 +0300
committerGitHub <noreply@github.com>2026-09-06 02:27:06 +0300
commit9e9bc8a144667a6b7debfd4f0c7a31ff0e11493a (patch)
tree03f1259f91fd9edeacfd4b904f2e84d4acf25232 /src/client/shell/state.rs
parent35b0dff96579f099e0436290085024424ffb289c (diff)
feat: manage multiple ssh machines from one client (#3670)
Diffstat (limited to 'src/client/shell/state.rs')
-rw-r--r--src/client/shell/state.rs328
1 files changed, 187 insertions, 141 deletions
diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs
index 60307f66..67bdff91 100644
--- a/src/client/shell/state.rs
+++ b/src/client/shell/state.rs
@@ -119,16 +119,27 @@ pub(super) struct ClientShellLayout {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum ClientMobileTarget {
+ Machine(ClientEndpointId),
NewWorkspace,
- Workspace(String),
+ Workspace {
+ endpoint_id: ClientEndpointId,
+ workspace_id: String,
+ },
NewTab,
- Tab(String),
- Agent(String),
+ Tab {
+ endpoint_id: ClientEndpointId,
+ tab_id: String,
+ },
+ Agent {
+ endpoint_id: ClientEndpointId,
+ pane_id: String,
+ },
Menu(usize),
}
#[derive(Default)]
pub(super) struct ShellHitMap {
+ pub(super) machines: Vec<MachineHit>,
pub(super) workspaces: Vec<WorkspaceHit>,
pub(super) workspace_body: Rect,
pub(super) workspace_scrollbar: Rect,
@@ -139,6 +150,7 @@ pub(super) struct ShellHitMap {
pub(super) popup: Option<PaneHit>,
pub(super) pane_splits: Vec<PaneSplitHit>,
pub(super) agents: Vec<(Rect, String)>,
+ pub(super) endpoint_agents: Vec<(Rect, ClientEndpointId, String)>,
pub(super) agent_body: Rect,
pub(super) agent_scrollbar: Rect,
pub(super) agent_scroll_metrics: Option<crate::pane::ScrollMetrics>,
@@ -164,7 +176,7 @@ pub(super) struct ShellHitMap {
pub(super) overlay_cancel: Rect,
pub(super) navigator_popup: Rect,
pub(super) navigator_search: Rect,
- pub(super) navigator_rows: Vec<(Rect, usize)>,
+ pub(super) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>,
pub(super) worktree_search: Rect,
pub(super) worktree_rows: Vec<(Rect, usize)>,
pub(super) help_popup: Rect,
@@ -215,6 +227,7 @@ pub(super) struct ClientPaneMouseGesture {
}
pub(super) struct ClientWorkspacePress {
+ pub(super) endpoint_id: ClientEndpointId,
pub(super) workspace_id: String,
pub(super) start_column: u16,
pub(super) start_row: u16,
@@ -271,6 +284,7 @@ pub(super) enum ClientChromeDrag {
pub(super) struct WorkspaceHit {
pub(super) rect: Rect,
+ pub(super) endpoint_id: ClientEndpointId,
pub(super) workspace_id: String,
pub(super) indented: bool,
pub(super) group_toggle: Option<(Rect, String)>,
@@ -279,12 +293,17 @@ pub(super) struct WorkspaceHit {
#[derive(Debug)]
pub(crate) enum ClientShellAction {
Endpoint {
+ endpoint_id: ClientEndpointId,
boot_id: String,
request: Box<crate::api::schema::Request>,
},
ClipboardWrite(Vec<u8>),
Request(ClientMessage),
OpenSafeWebUrl(String),
+ ActivateEndpoint {
+ endpoint_id: ClientEndpointId,
+ target: Option<ClientEndpointFocusTarget>,
+ },
ReplayMouse(Vec<crossterm::event::MouseEvent>),
Keybind(crate::input::KeybindAction),
}
@@ -366,11 +385,23 @@ pub(super) enum ClientNavigatorFilter {
Done,
}
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum ClientNavigatorTarget {
- Workspace(String),
- Tab(String),
- Pane(String),
+ Machine {
+ endpoint_id: ClientEndpointId,
+ },
+ Workspace {
+ endpoint_id: ClientEndpointId,
+ workspace_id: String,
+ },
+ Tab {
+ endpoint_id: ClientEndpointId,
+ tab_id: String,
+ },
+ Pane {
+ endpoint_id: ClientEndpointId,
+ pane_id: String,
+ },
}
#[derive(Clone, Debug)]
@@ -378,7 +409,8 @@ pub(super) struct ClientNavigatorRow {
pub(super) depth: u8,
pub(super) label: String,
pub(super) meta: String,
- pub(super) status: crate::api::schema::AgentStatus,
+ pub(super) status: Option<crate::api::schema::AgentStatus>,
+ pub(super) stale: bool,
pub(super) current: bool,
pub(super) target: ClientNavigatorTarget,
}
@@ -387,10 +419,10 @@ pub(super) struct ClientNavigatorRow {
pub(super) struct ClientNavigatorOverlay {
pub(super) query: String,
pub(super) search_focused: bool,
- pub(super) selected: usize,
+ pub(super) selected: Option<ClientNavigatorTarget>,
pub(super) scroll: usize,
pub(super) filter: Option<ClientNavigatorFilter>,
- pub(super) expanded_workspaces: HashSet<String>,
+ pub(super) expanded_workspaces: HashSet<(ClientEndpointId, String)>,
}
#[derive(Debug)]
@@ -741,12 +773,15 @@ pub(crate) enum ClientShellNotificationEffect {
}
pub(super) struct ClientPendingNotification {
+ pub(super) endpoint_id: ClientEndpointId,
pub(super) event: SemanticNotification,
pub(super) deadline: std::time::Instant,
+ pub(super) expires_at: std::time::Instant,
pub(super) validate_state: bool,
}
pub(super) struct ClientVisibleNotification {
+ pub(super) endpoint_id: ClientEndpointId,
pub(super) event: SemanticNotification,
pub(super) deadline: std::time::Instant,
}
@@ -861,6 +896,9 @@ pub(crate) struct ClientShellState {
pub(super) config: ClientShellConfig,
pub(super) snapshot: Option<Box<ClientShellSnapshot>>,
pub(super) pane_surface: Option<PaneSurfaceFrame>,
+ /// A future projection surface waits here until its matching snapshot arrives. The visible
+ /// pane surface always remains an exact snapshot pair.
+ pub(super) pending_pane_surface: Option<PaneSurfaceFrame>,
pub(super) graphics: crate::kitty_graphics::surface::ClientState,
pub(super) graphics_cell_size: crate::kitty_graphics::HostCellSize,
pub(super) popup_terminal_id: Option<String>,
@@ -887,6 +925,9 @@ pub(crate) struct ClientShellState {
pub(super) last_tab_bar_width: Option<u16>,
pub(super) last_composed_size: Option<(u16, u16)>,
pub(super) hits: ShellHitMap,
+ pub(super) endpoints: Vec<ClientShellEndpoint>,
+ pub(super) active_endpoint_id: ClientEndpointId,
+ pub(super) collapsed_endpoints: HashSet<ClientEndpointId>,
pub(super) mode: ClientShellMode,
pub(super) navigate_workspace_id: Option<String>,
pub(super) overlay: Option<ClientShellOverlay>,
@@ -917,14 +958,11 @@ pub(crate) struct ClientShellState {
pub(super) popup_pending: bool,
pub(super) popup_pending_deadline: Option<std::time::Instant>,
pub(super) next_request_id: u64,
- /// Methods advertised by this endpoint. `None` is used only by local tests
- /// and legacy construction paths; negotiated endpoint connections always
- /// install an explicit set.
- pub(super) endpoint_methods: Option<HashSet<String>>,
pub(super) pending_requests: HashMap<String, PendingEndpointRequest>,
pub(super) pending_integration_installs: usize,
pub(super) pending_notifications: Vec<ClientPendingNotification>,
pub(super) visible_notification: Option<ClientVisibleNotification>,
+ pub(super) queued_notifications: VecDeque<ClientVisibleNotification>,
pub(super) endpoint_notice_seen: HashSet<ClientEndpointNoticeKey>,
pub(super) visible_endpoint_notice: Option<ClientVisibleEndpointNotice>,
pub(super) outer_focused: Option<bool>,
@@ -1000,6 +1038,7 @@ impl ClientShellState {
config,
snapshot: None,
pane_surface: None,
+ pending_pane_surface: None,
graphics: crate::kitty_graphics::surface::ClientState::default(),
graphics_cell_size: crate::kitty_graphics::HostCellSize {
width_px: 1,
@@ -1029,6 +1068,9 @@ impl ClientShellState {
last_tab_bar_width: None,
last_composed_size: None,
hits: ShellHitMap::default(),
+ endpoints: vec![local_endpoint()],
+ active_endpoint_id: ClientEndpointId::Local,
+ collapsed_endpoints: HashSet::new(),
mode: ClientShellMode::Terminal,
navigate_workspace_id: None,
overlay,
@@ -1059,11 +1101,11 @@ impl ClientShellState {
popup_pending: false,
popup_pending_deadline: None,
next_request_id: 1,
- endpoint_methods: None,
pending_requests: HashMap::new(),
pending_integration_installs: 0,
pending_notifications: Vec::new(),
visible_notification: None,
+ queued_notifications: VecDeque::new(),
endpoint_notice_seen: HashSet::new(),
visible_endpoint_notice: None,
outer_focused: None,
@@ -1078,29 +1120,6 @@ impl ClientShellState {
}
}
- pub(crate) fn set_endpoint_methods(&mut self, methods: Option<Vec<String>>) {
- self.endpoint_methods = methods.map(|methods| methods.into_iter().collect());
- }
-
- pub(super) fn supports_endpoint_method(&self, method: &crate::api::schema::Method) -> bool {
- self.endpoint_methods
- .as_ref()
- .is_none_or(|methods| methods.contains(crate::api::api_method_name(method)))
- }
-
- fn focused_tab_count(&self) -> usize {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return 0;
- };
- snapshot
- .tabs
- .iter()
- .filter(|tab| {
- Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref()
- })
- .count()
- }
-
pub(super) fn resume_mobile_switcher_if_ready(&mut self) -> bool {
if !self.mobile_switcher_suspended || self.overlay.is_some() {
return false;
@@ -1173,16 +1192,80 @@ impl ClientShellState {
}
}
- pub(crate) fn set_snapshot(&mut self, mut snapshot: Box<ClientShellSnapshot>) {
+ pub(super) fn reset_endpoint_projection(&mut self) {
+ self.hits = ShellHitMap::default();
+ self.pane_surface = None;
+ self.pending_pane_surface = None;
+ self.input_leases = ClientInputLeases::default();
+ self.popup_terminal_id = None;
+ self.chrome_drag = None;
+ self.workspace_press = None;
+ self.tab_press = None;
+ self.workspace_scroll = 0;
+ self.agent_scroll = 0;
+ self.tab_scroll = 0;
+ self.mobile_switcher_scroll = 0;
+ self.reveal_focused_workspace = true;
+ self.reveal_mobile_workspace = false;
+ self.mobile_switcher_suspended = false;
+ self.reveal_focused_tab = true;
+ self.last_tab_bar_width = None;
+ self.last_composed_size = None;
+ self.pending_requests.clear();
+ self.pane_scroll_in_flight.clear();
+ self.pane_scroll_queued.clear();
+ self.pane_scroll_targets.clear();
+ self.popup_pending = false;
+ self.popup_pending_deadline = None;
+ self.pending_integration_installs = 0;
+ self.endpoint_notice_seen.clear();
+ self.visible_endpoint_notice = None;
+ self.endpoint_error = None;
+ self.navigate_workspace_id = None;
+ self.overlay = self
+ .config
+ .startup_onboarding
+ .then_some(ClientShellOverlay::Onboarding);
+ self.previous_pane_id = None;
+ self.pane_mouse_gesture = None;
+ self.url_click_consumes_until_up = false;
+ self.replaying_url_click = false;
+ self.selection = None;
+ self.last_pane_click = None;
+ self.selection_autoscroll = None;
+ self.selection_autoscroll_deadline = None;
+ self.selection_highlight_clear_deadline = None;
+ self.pending_word_selection = None;
+ self.copy_mode = None;
+ if self.mode == ClientShellMode::Copy {
+ self.mode = ClientShellMode::Terminal;
+ }
+ self.reset_copy_pipeline();
+ self.copy_feedback = None;
+ self.copy_feedback_deadline = None;
+ self.host_mouse_pixels = None;
+ self.dismissed_product_announcement = None;
+ }
+
+ pub(super) fn apply_active_snapshot(&mut self, mut snapshot: Box<ClientShellSnapshot>) {
snapshot
.commands
.retain(|command| command.action != crate::protocol::ClientShellCommandAction::Unknown);
- if self.snapshot.as_ref().is_some_and(|current| {
- current.boot_id == snapshot.boot_id && snapshot.revision < current.revision
- }) {
+ let graphics_scope = format!(
+ "{}:{}",
+ self.active_endpoint_id.storage_key(),
+ snapshot.boot_id
+ );
+ let endpoint_boot_changed =
+ self.snapshot.is_some() && self.graphics.scope() != graphics_scope;
+ if !endpoint_boot_changed
+ && self.snapshot.as_ref().is_some_and(|current| {
+ current.boot_id == snapshot.boot_id && snapshot.revision < current.revision
+ })
+ {
return;
}
- self.graphics.set_scope(&snapshot.boot_id);
+ self.graphics.set_scope(&graphics_scope);
let command_bindings_changed = self.snapshot.as_ref().is_none_or(|current| {
current.commands.len() != snapshot.commands.len()
|| current
@@ -1221,10 +1304,11 @@ impl ClientShellState {
self.local_config_diagnostic.as_deref(),
snapshot.config_diagnostic.as_deref(),
);
- let boot_changed = self
- .snapshot
- .as_ref()
- .is_some_and(|current| current.boot_id != snapshot.boot_id);
+ let boot_changed = endpoint_boot_changed
+ || self
+ .snapshot
+ .as_ref()
+ .is_some_and(|current| current.boot_id != snapshot.boot_id);
if boot_changed
|| self
.pane_surface
@@ -1234,55 +1318,7 @@ impl ClientShellState {
self.hits = ShellHitMap::default();
}
if boot_changed {
- self.pane_surface = None;
- self.input_leases = ClientInputLeases::default();
- self.popup_terminal_id = None;
- self.chrome_drag = None;
- self.workspace_press = None;
- self.tab_press = None;
- self.workspace_scroll = 0;
- self.agent_scroll = 0;
- self.tab_scroll = 0;
- self.mobile_switcher_scroll = 0;
- self.reveal_focused_workspace = true;
- self.reveal_mobile_workspace = false;
- self.mobile_switcher_suspended = false;
- self.reveal_focused_tab = true;
- self.last_tab_bar_width = None;
- self.last_composed_size = None;
- self.pending_requests.clear();
- self.pane_scroll_in_flight.clear();
- self.pane_scroll_queued.clear();
- self.pane_scroll_targets.clear();
- self.popup_pending = false;
- self.popup_pending_deadline = None;
- self.pending_integration_installs = 0;
- self.pending_notifications.clear();
- self.visible_notification = None;
- self.endpoint_notice_seen.clear();
- self.visible_endpoint_notice = None;
- self.endpoint_error = None;
- self.navigate_workspace_id = None;
- self.overlay = self
- .config
- .startup_onboarding
- .then_some(ClientShellOverlay::Onboarding);
- self.previous_pane_id = None;
- self.pane_mouse_gesture = None;
- self.url_click_consumes_until_up = false;
- self.replaying_url_click = false;
- self.selection = None;
- self.last_pane_click = None;
- self.selection_autoscroll = None;
- self.selection_autoscroll_deadline = None;
- self.selection_highlight_clear_deadline = None;
- self.pending_word_selection = None;
- self.copy_mode = None;
- self.reset_copy_pipeline();
- self.copy_feedback = None;
- self.copy_feedback_deadline = None;
- self.host_mouse_pixels = None;
- self.dismissed_product_announcement = None;
+ self.reset_endpoint_projection();
} else if let Some(previous) = self
.snapshot
.as_deref()
@@ -1474,16 +1510,64 @@ impl ClientShellState {
}
}
self.snapshot = Some(snapshot);
+ let pending_surface = self.pending_pane_surface.take();
+ if let Some(surface) = pending_surface {
+ let matching = self.snapshot.as_ref().is_some_and(|snapshot| {
+ surface.boot_id == snapshot.boot_id
+ && surface.projection_revision == snapshot.revision
+ });
+ if matching {
+ self.install_pane_surface(surface, false);
+ } else if self.snapshot.as_ref().is_some_and(|snapshot| {
+ surface.boot_id == snapshot.boot_id
+ && surface.projection_revision > snapshot.revision
+ }) {
+ self.pending_pane_surface = Some(surface);
+ }
+ }
self.resume_mobile_switcher_if_ready();
self.reconcile_input_source();
}
- pub(crate) fn set_pane_surface(&mut self, mut surface: PaneSurfaceFrame) {
+ pub(crate) fn has_presented_surface(&self) -> bool {
+ self.pane_surface.is_some()
+ }
+
+ pub(crate) fn set_pane_surface(&mut self, surface: PaneSurfaceFrame) {
+ let Some(snapshot) = self.snapshot.as_ref() else {
+ return;
+ };
+ if surface.boot_id != snapshot.boot_id || surface.projection_revision < snapshot.revision {
+ return;
+ }
+ if self.pane_surface.as_ref().is_some_and(|current| {
+ current.boot_id == surface.boot_id
+ && (surface.projection_revision < current.projection_revision
+ || (surface.projection_revision == current.projection_revision
+ && surface.surface_revision < current.surface_revision))
+ }) {
+ return;
+ }
+ if surface.projection_revision == snapshot.revision.saturating_add(1) {
+ // The next expected surface waits separately for its exact snapshot. Keeping the
+ // current pair avoids treating this speculative successor as presentation evidence.
+ self.pending_pane_surface = Some(surface);
+ self.hits = ShellHitMap::default();
+ return;
+ }
+ // A surface that skips one or more revisions supersedes any retained pair, but is still
+ // not rendered until its matching snapshot arrives. Retain it monotonically so delayed
+ // intermediate surfaces cannot replace it.
+ self.install_pane_surface(surface, true);
+ }
+
+ fn install_pane_surface(&mut self, mut surface: PaneSurfaceFrame, retain_future: bool) {
let Some(snapshot) = self.snapshot.as_ref() else {
return;
};
if surface.boot_id != snapshot.boot_id
|| surface.projection_revision < snapshot.revision
+ || (!retain_future && surface.projection_revision != snapshot.revision)
|| self.pane_surface.as_ref().is_some_and(|current| {
current.boot_id == surface.boot_id
&& (surface.projection_revision < current.projection_revision
@@ -1493,13 +1577,12 @@ impl ClientShellState {
{
return;
}
- if self
- .snapshot
- .as_ref()
- .is_none_or(|snapshot| snapshot.revision != surface.projection_revision)
- {
+ // A retained future surface is not presentable yet. Clear hit targets immediately; the
+ // exact-pair compose guard prevents it from replacing the visible frame.
+ if surface.projection_revision != snapshot.revision {
self.hits = ShellHitMap::default();
}
+ self.acknowledge_active_surface_agents(&surface);
let previous_popup = self.popup_terminal_id.clone();
let next_popup = surface
.popup
@@ -1696,45 +1779,8 @@ impl ClientShellState {
pub(crate) fn invalidate_pane_surface(&mut self) {
self.pane_surface = None;
+ self.pending_pane_surface = None;
self.hits = ShellHitMap::default();
self.host_mouse_pixels = None;
}
-
- fn wants_ascii_input(&self) -> bool {
- if let Some(overlay) = self.overlay.as_ref() {
- return matches!(
- overlay,
- ClientShellOverlay::ConfirmClose(_)
- | ClientShellOverlay::Help(_)
- | ClientShellOverlay::Navigator(_)
- | ClientShellOverlay::WorktreeRemove(_)
- | ClientShellOverlay::ContextMenu(_)
- | ClientShellOverlay::GlobalMenu(_)
- );
- }
- matches!(
- self.mode,
- ClientShellMode::Prefix
- | ClientShellMode::Navigate
- | ClientShellMode::Resize
- | ClientShellMode::Copy
- )
- }
-
- pub(crate) fn reconcile_input_source(&mut self) {
- // Keep the platform restore token while another window has focus. Restoring
- // through a global key injection is only safe after this client regains focus.
- if self.outer_focused == Some(false) {
- return;
- }
- let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input();
- if desired != self.ascii_input_source_active {
- self.ascii_input_source_active = desired;
- self.pending_input_source_changes.push(desired);
- }
- }
-
- pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> {
- std::mem::take(&mut self.pending_input_source_changes)
- }
}