From 207be3c771d281baae6e5fa0fb74be9a056e97a2 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Tue, 1 Sep 2026 15:48:22 +0300 Subject: 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 --- src/pane.rs | 118 ++++++++++++++++++++++++++++++------------------------------ 1 file changed, 60 insertions(+), 58 deletions(-) (limited to 'src/pane.rs') diff --git a/src/pane.rs b/src/pane.rs index 7340629c..48928034 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -44,8 +44,8 @@ use self::agent_detection::{ pub use self::terminal::InputState; use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; pub(crate) use self::terminal::{ - TerminalDirtyPatch, TerminalDirtyPatchOutcome, TerminalReadSnapshot, TerminalTextMatch, - TerminalTextPoint, TerminalWordMotion, + TerminalDirtyPatch, TerminalDirtyPatchOutcome, TerminalReadSnapshot, TerminalSearchDirection, + TerminalSearchWindow, TerminalTextPoint, TerminalWordMotion, }; pub use self::{ state::PaneState, @@ -56,21 +56,6 @@ const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration:: const PANE_TERM: &str = "xterm-256color"; const PANE_COLORTERM: &str = "truecolor"; -#[cfg(test)] -thread_local! { - static AGGREGATE_INPUT_STATE_READS: Cell = const { Cell::new(0) }; -} - -#[cfg(test)] -pub(crate) fn reset_aggregate_input_state_reads() { - AGGREGATE_INPUT_STATE_READS.set(0); -} - -#[cfg(test)] -pub(crate) fn aggregate_input_state_reads() -> usize { - AGGREGATE_INPUT_STATE_READS.get() -} - fn apply_pane_terminal_env(cmd: &mut CommandBuilder) { // Each pane is rendered by herdr's own terminal layer, not the outer terminal // that launched the app. Advertising the inherited TERM leaks the host terminal @@ -1053,6 +1038,7 @@ pub struct PaneRuntime { child_wait_completed: Option>, kitty_keyboard_flags: Arc, content_seq: Arc, + content_write_lock: Arc>, detection_content_seq: Arc, full_lifecycle_authority_active: Arc, detect_reset_notify: Arc, @@ -1175,14 +1161,6 @@ impl PaneRuntimeIo { } } - async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError> { - match self { - PaneRuntimeIo::Actor(actor) => actor.write_user_input(bytes).await, - #[cfg(test)] - PaneRuntimeIo::TestChannel { sender, .. } => sender.send(bytes).await, - } - } - fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError> { match self { PaneRuntimeIo::Actor(actor) => actor.try_write_user_input(bytes), @@ -1926,6 +1904,7 @@ impl PaneRuntime { let reported_cwd = Arc::new(Mutex::new(None)); let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags)); let content_seq = Arc::new(AtomicU64::new(0)); + let content_write_lock = Arc::new(Mutex::new(())); let detection_content_seq = Arc::new(AtomicU64::new(0)); let io = { @@ -1934,6 +1913,7 @@ impl PaneRuntime { let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); let content_seq = content_seq.clone(); + let content_write_lock = content_write_lock.clone(); let detection_content_seq = detection_content_seq.clone(); let child_pid = child_pid.clone(); let read_events = events.clone(); @@ -1941,11 +1921,16 @@ impl PaneRuntime { let rt = tokio::runtime::Handle::current(); let delay_rt = rt.clone(); let on_read = Box::new(move |bytes: &[u8]| { + let _content_write_guard = match content_write_lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; content_seq.fetch_add(1, Ordering::AcqRel); let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); content_seq.fetch_add(1, Ordering::Release); + drop(_content_write_guard); publish_terminal_bells(pane_id, result.terminal_bells, &read_events); observe_detection_content_change(bytes, &detection_content_seq); let title_requested = @@ -2014,6 +1999,7 @@ impl PaneRuntime { child_wait_completed: None, kitty_keyboard_flags, content_seq, + content_write_lock, detection_content_seq, full_lifecycle_authority_active, detect_reset_notify, @@ -2061,6 +2047,7 @@ impl PaneRuntime { } let terminal = Arc::new(PaneTerminal::new(pane_terminal)); let kitty_keyboard_flags = Arc::new(AtomicU16::new(0)); + let content_write_lock = Arc::new(Mutex::new(())); let spawned = crate::pty::backend::spawn_with_portable_pty(rows, cols, cmd) .inspect_err(|err| error!(pane = pane_id.raw(), err = %err, "{spawn_error_message}"))?; @@ -2104,17 +2091,23 @@ impl PaneRuntime { let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); let content_seq = content_seq.clone(); + let content_write_lock = content_write_lock.clone(); let detection_content_seq = detection_content_seq.clone(); let child_pid = child_pid.clone(); let events = events.clone(); let reported_cwd = reported_cwd.clone(); let rt = tokio::runtime::Handle::current(); let on_read = Box::new(move |bytes: &[u8]| { + let _content_write_guard = match content_write_lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; content_seq.fetch_add(1, Ordering::AcqRel); let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); content_seq.fetch_add(1, Ordering::Release); + drop(_content_write_guard); publish_terminal_bells(pane_id, result.terminal_bells, &events); if agent_detection == AgentDetection::Enabled { observe_detection_content_change(bytes, &detection_content_seq); @@ -2570,6 +2563,7 @@ impl PaneRuntime { child_wait_completed: Some(child_wait_completed), kitty_keyboard_flags, content_seq, + content_write_lock, detection_content_seq, full_lifecycle_authority_active, detect_reset_notify, @@ -2598,11 +2592,6 @@ impl PaneRuntime { self.detect_reset_notify.clone() } - #[cfg(test)] - pub(crate) fn agent_detection_enabled_for_test(&self) -> bool { - self.detect_handle.is_some() - } - pub fn set_full_lifecycle_authority_active(&self, active: bool) { let previous = self .full_lifecycle_authority_active @@ -2630,9 +2619,16 @@ impl PaneRuntime { return; } self.current_size.set(size); + let _content_write_guard = match self.content_write_lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + self.content_seq.fetch_add(1, Ordering::AcqRel); let terminal_responses = self .terminal .resize(rows, cols, cell_width_px, cell_height_px); + self.content_seq.fetch_add(1, Ordering::Release); + drop(_content_write_guard); mark_detection_content_changed(&self.detection_content_seq); self.io.resize( rows, @@ -2674,23 +2670,20 @@ impl PaneRuntime { self.terminal.scroll_metrics() } - pub(crate) fn search_text_matches( + pub(crate) fn search_text_window( &self, query: &str, case_sensitive: bool, - ) -> Vec { - self.terminal.search_text_matches(query, case_sensitive) - } - - pub(crate) fn text_match_is_current(&self, text_match: crate::pane::TerminalTextMatch) -> bool { - self.terminal.text_match_is_current(text_match) - } - - pub(crate) fn text_matches_are_current( - &self, - text_matches: &[crate::pane::TerminalTextMatch], - ) -> Vec { - self.terminal.text_matches_are_current(text_matches) + direction: crate::pane::TerminalSearchDirection, + cursor: crate::pane::TerminalTextPoint, + previous: Option<( + crate::pane::TerminalTextPoint, + crate::pane::TerminalTextPoint, + )>, + limit: usize, + ) -> crate::pane::TerminalSearchWindow { + self.terminal + .search_text_window(query, case_sensitive, direction, cursor, previous, limit) } pub(crate) fn word_motion_target( @@ -2702,17 +2695,23 @@ impl PaneRuntime { self.terminal.word_motion_target(row, col, motion) } + pub(crate) fn terminal_dimensions(&self) -> Option<(u16, u16)> { + self.terminal.dimensions() + } + + pub(crate) fn paragraph_motion_target( + &self, + row: u32, + direction: i8, + ) -> Option { + self.terminal.paragraph_motion_target(row, direction) + } + #[cfg(any(unix, test))] pub fn input_state(&self) -> Option { - #[cfg(test)] - AGGREGATE_INPUT_STATE_READS.set(AGGREGATE_INPUT_STATE_READS.get() + 1); self.terminal.input_state() } - pub fn keyboard_report_all_requested(&self) -> bool { - self.terminal.keyboard_report_all_requested() - } - pub fn bracketed_paste_enabled(&self) -> bool { self.terminal.bracketed_paste_enabled() } @@ -2844,15 +2843,15 @@ impl PaneRuntime { self.terminal.keyboard_protocol(fallback) } + pub fn modify_other_keys_level(&self) -> u8 { + self.terminal.modify_other_keys_level() + } + pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { self.terminal .encode_terminal_key(key, self.keyboard_protocol()) } - pub async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError> { - self.io.send_bytes(bytes).await - } - pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError> { self.io.try_send_bytes(bytes) } @@ -2861,10 +2860,6 @@ impl PaneRuntime { self.io.send_bytes_after(bytes, delay); } - pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError> { - self.send_bytes(self.paste_payload(text)).await - } - pub fn try_send_paste(&self, text: String) -> Result<(), mpsc::error::TrySendError> { self.try_send_bytes(self.paste_payload(text)) } @@ -3047,6 +3042,10 @@ impl PaneRuntime { } pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) { + let _content_write_guard = match self.content_write_lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; self.content_seq.fetch_add(1, Ordering::AcqRel); let (tx, _rx) = mpsc::channel(1); let _ = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx); @@ -3091,6 +3090,7 @@ impl PaneRuntime { child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), content_seq: Arc::new(AtomicU64::new(0)), + content_write_lock: Arc::new(Mutex::new(())), detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), @@ -3660,6 +3660,7 @@ mod tests { child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), content_seq: Arc::new(AtomicU64::new(0)), + content_write_lock: Arc::new(Mutex::new(())), detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), @@ -3692,6 +3693,7 @@ mod tests { child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), content_seq: Arc::new(AtomicU64::new(0)), + content_write_lock: Arc::new(Mutex::new(())), detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), -- cgit v1.2.3