aboutsummaryrefslogtreecommitdiff
path: root/src/server/clients.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/server/clients.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/server/clients.rs')
-rw-r--r--src/server/clients.rs423
1 files changed, 304 insertions, 119 deletions
diff --git a/src/server/clients.rs b/src/server/clients.rs
index 0d3d9ae0..d380e2c3 100644
--- a/src/server/clients.rs
+++ b/src/server/clients.rs
@@ -1,13 +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 },
}
@@ -20,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<u32>,
+) -> 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]
@@ -29,48 +62,54 @@ 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<Box<crate::config::LiveKeybindConfig>>,
/// 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<crate::terminal_theme::HostAppearance>,
- /// 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<bool>,
- /// 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.
- 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<crate::terminal_theme::HostAppearance>,
+ /// 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<bool>,
+ /// Last focused-pane report-all demand sent to a client-owned shell.
+ pub(crate) host_keyboard_report_all_active: Option<bool>,
/// 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<bool>,
/// Last SGR pixel provenance mode sent to this client.
pub(crate) host_sgr_pixels_active: Option<bool>,
- /// Last Kitty report-all mode sent to this client's host terminal.
- pub(crate) host_keyboard_report_all_active: Option<bool>,
+ /// 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<ClientShellPressId, ClientShellHeldInput>,
/// Temporary files staged from this client's local clipboard image pastes.
pub(crate) staged_clipboard_files: Vec<PathBuf>,
+ /// Last coherent shell replacement sent to this client.
+ pub(crate) shell_snapshot: Option<crate::protocol::ClientShellSnapshot>,
+ /// Monotonic shell replacement revision for this connection.
+ pub(crate) shell_projection_revision: u64,
+ /// Whether this shell is waiting for one ordered endpoint command response.
+ pub(crate) shell_endpoint_command_in_flight: bool,
+ /// Whether this shell uses the endpoint-owned keymap rather than a client-owned keymap.
+ pub(crate) shell_uses_endpoint_keybindings: bool,
/// Channels for sending framed ServerMessage data to the client writer thread.
pub(crate) writer: Option<ClientWriter>,
}
@@ -80,62 +119,53 @@ 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<bool>,
last_activity: u64,
render_encoding: RenderEncoding,
writer: Option<ClientWriter>,
) -> 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<Box<crate::config::LiveKeybindConfig>>,
terminal_size: (u16, u16),
cell_size: crate::kitty_graphics::HostCellSize,
- host_terminal_theme: crate::terminal_theme::TerminalTheme,
- outer_terminal_focus: Option<bool>,
last_activity: u64,
render_encoding: RenderEncoding,
- pending_terminal_attach: bool,
writer: Option<ClientWriter>,
) -> 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,
+ shell_endpoint_command_in_flight: false,
+ shell_uses_endpoint_keybindings: false,
writer,
}
}
@@ -144,62 +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 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 drain_shell_held_inputs(&mut self) -> Vec<ClientShellHeldInput> {
+ self.shell_held_inputs
+ .drain()
+ .map(|(_, held)| held)
+ .collect()
}
- 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);
}
}
@@ -228,41 +347,37 @@ impl ClientConnection {
true
}
- pub(crate) fn update_outer_focus_from_events(
- &mut self,
- events: &[crate::raw_input::RawInputEvent],
- ) -> Option<bool> {
- 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()?;
-
- self.outer_terminal_focus = Some(next_focus);
- Some(next_focus)
+ pub(crate) fn deferred_render(&self) -> DeferredRender {
+ if self.render_pending {
+ DeferredRender::Full
+ } else {
+ DeferredRender::None
+ }
}
-}
-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 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 latest_app_client(clients: &HashMap<u64, ClientConnection>) -> Option<u64> {
+pub(crate) fn latest_shell_client(clients: &HashMap<u64, ClientConnection>) -> Option<u64> {
clients
.iter()
- .filter(|(_, client)| client.is_full_app_client())
+ .filter(|(_, client)| client.is_shell_client())
.max_by_key(|(_, client)| client.last_activity)
.map(|(&client_id, _)| client_id)
}
@@ -293,7 +408,7 @@ pub(crate) fn render_targets(
.iter()
.filter(|(_, client)| {
client.writer.is_some()
- && (client.is_full_app_client()
+ && (client.is_shell_client()
|| matches!(
client.mode,
ClientConnectionMode::TerminalAttach { .. }
@@ -314,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),
+ ..
+ }
+ ));
+ }
+}