diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/config.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (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/client/shell/config.rs')
| -rw-r--r-- | src/client/shell/config.rs | 503 |
1 files changed, 503 insertions, 0 deletions
diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs new file mode 100644 index 00000000..0273cefb --- /dev/null +++ b/src/client/shell/config.rs @@ -0,0 +1,503 @@ +use super::*; + +pub(super) fn merged_config_diagnostic( + local: Option<&str>, + endpoint: Option<&str>, +) -> Option<String> { + match (local, endpoint) { + (Some(local), Some(endpoint)) if local == endpoint => { + Some(format!("client + endpoint: {local}")) + } + (Some(local), Some(endpoint)) => Some(format!("client: {local}\nendpoint: {endpoint}")), + (Some(local), None) => Some(local.to_owned()), + (None, Some(endpoint)) => Some(endpoint.to_owned()), + (None, None) => None, + } +} + +impl ClientShellState { + pub(super) fn set_local_config_diagnostic(&mut self, diagnostic: Option<String>) { + self.local_config_diagnostic = diagnostic; + self.config_diagnostic = merged_config_diagnostic( + self.local_config_diagnostic.as_deref(), + self.snapshot + .as_deref() + .and_then(|snapshot| snapshot.config_diagnostic.as_deref()), + ); + } + + pub(super) fn persist_chrome_preferences(&mut self, outcome: &mut ClientShellInput) { + let Some(path) = self.config.preferences_path.as_deref() else { + return; + }; + let mut collapsed_groups = self.collapsed_groups.iter().cloned().collect::<Vec<_>>(); + collapsed_groups.sort(); + let preferences = preferences::ClientChromePreferences { + sidebar_width: self.sidebar_width_manual.then_some(self.sidebar_width), + sidebar_section_split: self + .sidebar_section_split_manual + .then_some(self.sidebar_section_split), + sidebar_collapsed: self + .sidebar_collapsed_manual + .then_some(self.sidebar_collapsed), + 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); + outcome.repaint = true; + } + } + + pub(crate) fn reload_client_config(&mut self) { + match crate::config::load_live_config() { + Ok(loaded) => { + let agent_panel_sort = self.config.agent_panel_sort; + let diagnostics = self.config.apply_live_config( + &loaded.config, + &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; + } + if self.agent_panel_sort_manual { + self.config.agent_panel_sort = agent_panel_sort; + } + self.set_local_config_diagnostic(self.config.local_config_diagnostic(&diagnostics)); + if let Some(snapshot) = self.snapshot.as_deref() { + let profile = snapshot.server_keybindings_toml.clone(); + let commands = snapshot.commands.clone(); + if let Err(err) = self + .config + .apply_snapshot_keybindings(profile.as_deref(), &commands) + { + self.endpoint_error = Some(err); + } + } + } + Err(diagnostics) => { + self.set_local_config_diagnostic(self.config.local_config_diagnostic(&diagnostics)); + } + } + self.reconcile_input_source(); + } +} + +impl ClientShellConfig { + pub(crate) fn from_config(config: &Config) -> Self { + let theme_runtime = crate::app::client_theme_runtime_from_config(config); + Self { + sidebar_width: config.ui.sidebar_width, + sidebar_min_width: config.ui.sidebar_min_width, + sidebar_max_width: config.ui.sidebar_max_width, + sidebar_start_collapsed: config.ui.sidebar_start_collapsed, + sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, + mobile_width_threshold: config.ui.mobile_width_threshold, + tab_bar_position: config.ui.tab_bar_position, + hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab, + spaces: config.ui.sidebar.spaces.clone(), + agents: config.ui.sidebar.agents.clone(), + agent_panel_sort: config.ui.agent_panel_sort, + status_indicators: config.ui.status_indicators, + sound_enabled: config.ui.sound.enabled, + toast_delivery: config.ui.toast.delivery, + toast_delay_seconds: config.ui.toast.delay_seconds, + toast_position: config.ui.toast.herdr.position, + copy_on_select: config.ui.copy_on_select, + clipboard_toast_enabled: config.ui.toast.clipboard.enabled, + clipboard_toast_position: config.ui.toast.clipboard.position, + theme_name: theme_runtime.manual_name.clone(), + theme_runtime, + palette: crate::app::client_palette_from_config(config), + keybinds: config + .live_keybinds_with_diagnostics() + .map(|(keybinds, _diagnostics)| keybinds) + .unwrap_or_else(|_diagnostics| LiveKeybindConfig { + prefix: config.prefix_key(), + keybinds: config.keybinds(), + }), + local_keys: config.keys.clone(), + keybinding_source: ClientShellKeybindingSource::Local, + prompt_new_tab_name: config.ui.prompt_new_tab_name, + prompt_new_workspace_name: config.ui.prompt_new_workspace_name, + confirm_close: config.ui.confirm_close, + mouse_capture: config.ui.mouse_capture, + mouse_scroll_lines: config.ui.mouse_scroll_lines(), + right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(), + 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, + startup_onboarding: false, + } + } + + pub(crate) fn with_startup_config_diagnostic(mut self, diagnostic: Option<String>) -> Self { + self.startup_config_diagnostic = diagnostic; + self + } + + pub(crate) fn with_startup_onboarding(mut self, show: bool) -> Self { + self.startup_onboarding = show; + self + } + + pub(crate) fn with_keybinding_source(mut self, source: ClientShellKeybindingSource) -> Self { + self.keybinding_source = source; + self.keybinds.keybinds.custom_commands.clear(); + self + } + + pub(crate) fn uses_endpoint_keybindings(&self) -> bool { + self.keybinding_source == ClientShellKeybindingSource::Endpoint + } + + pub(super) fn local_config_diagnostic(&self, diagnostics: &[String]) -> Option<String> { + if self.uses_endpoint_keybindings() { + crate::config::config_diagnostic_summary_without_keybindings(diagnostics) + } else { + crate::config::config_diagnostic_summary(diagnostics) + } + } + + pub(crate) fn with_local_endpoint(self, socket_path: &std::path::Path) -> Self { + self.with_preferences_path(preferences::path_for_local_endpoint(socket_path)) + } + + pub(super) fn with_preferences_path(mut self, path: std::path::PathBuf) -> Self { + self.preferences = preferences::load(&path).unwrap_or_default(); + self.preferences_path = Some(path); + self + } + + pub(super) fn apply_snapshot_keybindings( + &mut self, + profile: Option<&str>, + commands: &[crate::protocol::ClientShellCommand], + ) -> Result<(), String> { + let mut keybinds = match self.keybinding_source { + ClientShellKeybindingSource::Endpoint => crate::config::keybindings_from_profile_toml( + profile.ok_or("endpoint did not publish its keybindings")?, + )?, + ClientShellKeybindingSource::RemoteLocal => return Ok(()), + ClientShellKeybindingSource::Local => { + let mut config = crate::config::Config { + keys: self.local_keys.clone(), + ..Default::default() + }; + config.keys.command = commands + .iter() + .map(|command| crate::config::CommandKeybindConfig { + key: if command.binding_labels.len() == 1 { + crate::config::BindingConfig::One(command.binding_labels[0].clone()) + } else { + crate::config::BindingConfig::Many(command.binding_labels.clone()) + }, + // The client never executes this field; preserve the opaque endpoint ID + // through the shared config collision resolver. + command: command.command_id.clone(), + action_type: match command.action { + crate::protocol::ClientShellCommandAction::Shell => { + crate::config::CommandKeybindType::Shell + } + crate::protocol::ClientShellCommandAction::Pane => { + crate::config::CommandKeybindType::Pane + } + crate::protocol::ClientShellCommandAction::Popup => { + crate::config::CommandKeybindType::Popup + } + crate::protocol::ClientShellCommandAction::PluginAction => { + crate::config::CommandKeybindType::PluginAction + } + }, + description: command.description.clone(), + width: None, + height: None, + }) + .collect(); + config + .live_keybinds_with_diagnostics() + .map(|(keybinds, _diagnostics)| keybinds) + .map_err(|diagnostics| diagnostics.join("; "))? + } + }; + if self.keybinding_source == ClientShellKeybindingSource::Endpoint { + for command in commands { + keybinds + .keybinds + .custom_commands + .push(crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::from_labels( + &command.binding_labels, + )?, + label: command.binding_label.clone(), + command: command.command_id.clone(), + action: command.action.into(), + description: command.description.clone(), + width: None, + height: None, + }); + } + } + self.keybinds = keybinds; + Ok(()) + } + + pub(super) fn apply_live_config( + &mut self, + config: &Config, + load_diagnostics: &[String], + invalid_sections: &[String], + ) -> Vec<String> { + let mut diagnostics = load_diagnostics.to_vec(); + let invalid_section = + |section: &str| invalid_sections.iter().any(|invalid| invalid == section); + + if !invalid_section("keys") + && self.keybinding_source != ClientShellKeybindingSource::Endpoint + { + match config.live_keybinds_with_diagnostics() { + Ok((mut keybinds, keybind_diagnostics)) => { + self.local_keys = config.keys.clone(); + if self.keybinding_source == ClientShellKeybindingSource::RemoteLocal { + keybinds.keybinds.custom_commands.clear(); + } + self.keybinds = keybinds; + diagnostics.extend(keybind_diagnostics); + } + Err(keybind_diagnostics) => diagnostics.extend( + keybind_diagnostics + .into_iter() + .map(|diagnostic| format!("{diagnostic}; kept current keybinds")), + ), + } + } + + if !invalid_section("ui") { + if let Some(diagnostic) = config.invalid_sidebar_bounds_diagnostic() { + diagnostics.push(format!("{diagnostic}; keeping previous [ui] settings")); + } else { + let ui = &config.ui; + diagnostics.extend(ui.sound.diagnostics()); + self.sidebar_width = ui.sidebar_width; + self.sidebar_min_width = ui.sidebar_min_width; + self.sidebar_max_width = ui.sidebar_max_width; + self.sidebar_collapsed_mode = ui.sidebar_collapsed_mode; + self.mobile_width_threshold = ui.mobile_width_threshold; + self.tab_bar_position = ui.tab_bar_position; + self.hide_tab_bar_when_single_tab = ui.hide_tab_bar_when_single_tab; + self.spaces = ui.sidebar.spaces.clone(); + self.agents = ui.sidebar.agents.clone(); + self.agent_panel_sort = ui.agent_panel_sort; + self.status_indicators = ui.status_indicators; + self.sound_enabled = ui.sound.enabled; + self.toast_delivery = ui.toast.delivery; + self.toast_delay_seconds = ui.toast.delay_seconds; + self.toast_position = ui.toast.herdr.position; + self.copy_on_select = ui.copy_on_select; + self.clipboard_toast_enabled = ui.toast.clipboard.enabled; + self.clipboard_toast_position = ui.toast.clipboard.position; + self.prompt_new_tab_name = ui.prompt_new_tab_name; + self.prompt_new_workspace_name = ui.prompt_new_workspace_name; + self.confirm_close = ui.confirm_close; + 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; + } + } + + if !invalid_section("theme") { + self.theme_runtime = crate::app::client_theme_runtime_from_config(config); + self.theme_name = self.theme_runtime.manual_name.clone(); + self.palette = crate::app::client_palette_from_config(config); + } + if !invalid_section("experimental") { + self.switch_ascii_input_source_in_prefix = + config.experimental.switch_ascii_input_source_in_prefix; + } + + diagnostics + } + + pub(super) fn layout( + &self, + cols: u16, + rows: u16, + sidebar_collapsed: bool, + tab_count: usize, + sidebar_width: u16, + ) -> ClientShellLayout { + if cols <= self.mobile_width_threshold { + let header_height = rows.min(2); + return ClientShellLayout { + sidebar: Rect::default(), + tab_bar: Rect::default(), + mobile_header: Rect::new(0, 0, cols, header_height), + pane_surface: Rect::new(0, header_height, cols, rows.saturating_sub(header_height)), + }; + } + + let sidebar_width = if sidebar_collapsed { + match self.sidebar_collapsed_mode { + SidebarCollapsedModeConfig::Compact => 4, + SidebarCollapsedModeConfig::Hidden => 0, + } + } else { + let (min, max) = crate::config::validated_sidebar_bounds( + self.sidebar_min_width, + self.sidebar_max_width, + ) + .unwrap_or((18, 36)); + sidebar_width.clamp(min, max) + } + .min(cols.saturating_sub(1)); + let main = Rect::new(sidebar_width, 0, cols.saturating_sub(sidebar_width), rows); + let show_tab_bar = rows > 1 && !(self.hide_tab_bar_when_single_tab && tab_count == 1); + let tab_height = u16::from(show_tab_bar); + let (tab_bar, pane_surface) = match self.tab_bar_position { + TabBarPositionConfig::Top => ( + Rect::new(main.x, 0, main.width, tab_height), + Rect::new( + main.x, + tab_height, + main.width, + rows.saturating_sub(tab_height), + ), + ), + TabBarPositionConfig::Bottom => ( + Rect::new( + main.x, + rows.saturating_sub(tab_height), + main.width, + tab_height, + ), + Rect::new(main.x, 0, main.width, rows.saturating_sub(tab_height)), + ), + }; + + ClientShellLayout { + sidebar: Rect::new(0, 0, sidebar_width, rows), + tab_bar, + mobile_header: Rect::default(), + pane_surface, + } + } + + pub(crate) fn initial_surface_size(&self, cols: u16, rows: u16) -> ClientSurfaceSize { + let sidebar_collapsed = self + .preferences + .sidebar_collapsed + .unwrap_or(self.sidebar_start_collapsed); + let (min_width, max_width) = + crate::config::validated_sidebar_bounds(self.sidebar_min_width, self.sidebar_max_width) + .unwrap_or((18, 36)); + let sidebar_width = self + .preferences + .sidebar_width + .unwrap_or(self.sidebar_width) + .clamp(min_width, max_width); + let surface = self + .layout(cols, rows, sidebar_collapsed, 0, sidebar_width) + .pane_surface; + ClientSurfaceSize { + cols: surface.width.max(1), + rows: surface.height.max(1), + } + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyModifiers}; + + use super::*; + + #[test] + fn live_reload_applies_client_owned_sections() { + let mut shell = ClientShellConfig::from_config(&Config::default()); + let mut next = Config::default(); + next.ui.sidebar_width = 31; + next.ui.tab_bar_position = TabBarPositionConfig::Bottom; + next.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; + next.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; + next.ui.sidebar.agents.row_gap = 2; + next.keys.prefix = "ctrl+a".to_owned(); + + let diagnostics = shell.apply_live_config(&next, &[], &[]); + + assert!(diagnostics.is_empty()); + assert_eq!(shell.sidebar_width, 31); + assert_eq!(shell.tab_bar_position, TabBarPositionConfig::Bottom); + assert_eq!( + shell.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert_eq!( + shell.status_indicators, + crate::config::StatusIndicatorStyle::Symbols + ); + assert_eq!(shell.agents.row_gap, 2); + assert_eq!( + shell.keybinds.prefix, + (KeyCode::Char('a'), KeyModifiers::CONTROL) + ); + } + + #[test] + fn initial_surface_size_uses_persisted_endpoint_chrome() { + let path = std::env::temp_dir().join(format!( + "herdr-initial-shell-preferences-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + preferences::store( + &path, + preferences::ClientChromePreferences { + sidebar_width: Some(31), + sidebar_collapsed: Some(true), + ..preferences::ClientChromePreferences::default() + }, + ) + .expect("persist endpoint chrome"); + let config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let initial = config.initial_surface_size(100, 30); + let state = ClientShellState::new(config); + assert_eq!(initial, state.surface_size(100, 30)); + std::fs::remove_file(path).expect("remove endpoint chrome"); + } + + #[test] + fn live_reload_preserves_invalid_client_owned_sections() { + let mut initial = Config::default(); + initial.ui.sidebar_width = 29; + initial.keys.prefix = "ctrl+x".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(); + let invalid_sections = vec!["ui".to_owned(), "keys".to_owned()]; + shell.apply_live_config(&invalid, &[], &invalid_sections); + + assert_eq!(shell.sidebar_width, 29); + assert_eq!( + shell.keybinds.prefix, + (KeyCode::Char('x'), KeyModifiers::CONTROL) + ); + } +} |
