From f0199c26ca5adce5ee9e793106714b6e69cb4633 Mon Sep 17 00:00:00 2001 From: longdd Date: Tue, 15 Sep 2026 03:10:02 +0700 Subject: [PATCH 01/10] feat(terminal): add stepped zoom ratchet Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/zedra-terminal/src/lib.rs | 2 + crates/zedra-terminal/src/view.rs | 422 +++++++++++++++++++++- crates/zedra-terminal/src/zoom_ratchet.rs | 340 +++++++++++++++++ 3 files changed, 761 insertions(+), 3 deletions(-) create mode 100644 crates/zedra-terminal/src/zoom_ratchet.rs diff --git a/crates/zedra-terminal/src/lib.rs b/crates/zedra-terminal/src/lib.rs index b015aa67..3901fe5a 100644 --- a/crates/zedra-terminal/src/lib.rs +++ b/crates/zedra-terminal/src/lib.rs @@ -6,6 +6,7 @@ mod selection; pub mod terminal; pub mod theme; pub mod view; +pub mod zoom_ratchet; pub use element::{TerminalElement, TerminalElementLayout}; pub use input::*; @@ -14,6 +15,7 @@ pub use keys::*; pub use terminal::*; pub use theme::{AnsiPalette, TerminalTheme}; pub use view::*; +pub use zoom_ratchet::{TerminalFontSize, TerminalZoomRatchet, ZoomStep}; use gpui::*; diff --git a/crates/zedra-terminal/src/view.rs b/crates/zedra-terminal/src/view.rs index 317ef1b1..a76050dc 100644 --- a/crates/zedra-terminal/src/view.rs +++ b/crates/zedra-terminal/src/view.rs @@ -15,9 +15,10 @@ use crate::TerminalTheme; use crate::element::TerminalElement; use crate::selection::TerminalSelectionDocument; use crate::terminal::{Terminal, TerminalContent, TerminalEvent}; +use crate::zoom_ratchet::{TerminalFontSize, TerminalZoomRatchet}; const FALLBACK_CELL_WIDTH: f32 = 9.0; -const TERMINAL_LINE_HEIGHT: f32 = 16.0; +pub const TERMINAL_LINE_HEIGHT: f32 = TerminalFontSize::DEFAULT.line_height_f32(); const TOUCH_SCROLL_SUPPRESSION_AFTER_SCROLL_TO_BOTTOM: Duration = Duration::from_millis(1000); /// Thread-safe buffer for receiving PTY output. @@ -121,6 +122,13 @@ pub struct TerminalView { /// drop focus — the bar keeps sending keystrokes to this terminal. pub retains_focus_without_keyboard: bool, terminal_theme: TerminalTheme, + font_size: TerminalFontSize, + zoom_ratchet: TerminalZoomRatchet, + pinch_claimed: bool, + pinch_start_font_size: TerminalFontSize, + viewport: Size, + interaction_signaled: bool, + outbound_input_signaled: bool, _event_task: Task<()>, _subscriptions: Vec, } @@ -191,11 +199,50 @@ impl TerminalView { is_alt_screen: false, retains_focus_without_keyboard: false, terminal_theme: TerminalTheme::dark(), + font_size: TerminalFontSize::DEFAULT, + zoom_ratchet: TerminalZoomRatchet::new(TerminalFontSize::DEFAULT), + pinch_claimed: false, + pinch_start_font_size: TerminalFontSize::DEFAULT, + viewport, + interaction_signaled: false, + outbound_input_signaled: false, _event_task: event_task, _subscriptions: vec![], } } + pub fn new_with_font_size( + terminal_id: String, + window: &mut Window, + viewport: Size, + font_size: TerminalFontSize, + cx: &mut Context, + ) -> Self { + let initial_grid_size = + TerminalView::compute_grid_size_for_font_size(window, viewport, font_size); + let mut this = Self::new(terminal_id, window, viewport, cx); + this.set_font_size(font_size, cx); + this.terminal.update(cx, |terminal, _| { + terminal.resize( + initial_grid_size.columns, + initial_grid_size.rows, + initial_grid_size.cell_width, + initial_grid_size.line_height, + ); + }); + this + } + + pub fn set_font_size(&mut self, font_size: TerminalFontSize, cx: &mut Context) { + self.font_size = TerminalFontSize::new(font_size.as_u8()); + self.zoom_ratchet.set_font_size(self.font_size); + cx.notify(); + } + + pub fn font_size(&self) -> TerminalFontSize { + self.font_size + } + pub fn set_terminal_id(&mut self, terminal_id: String) { self.terminal_id = terminal_id; } @@ -238,7 +285,15 @@ impl TerminalView { } pub fn compute_grid_size(window: &mut Window, viewport: Size) -> TerminalGridSize { - let line_height = px(TERMINAL_LINE_HEIGHT); + Self::compute_grid_size_for_font_size(window, viewport, TerminalFontSize::DEFAULT) + } + + pub fn compute_grid_size_for_font_size( + window: &mut Window, + viewport: Size, + font_size: TerminalFontSize, + ) -> TerminalGridSize { + let line_height = font_size.line_height(); let cell_width = Self::measure_cell_width(window, line_height); Self::compute_grid_size_with_metrics(viewport, cell_width, line_height) } @@ -342,6 +397,88 @@ impl TerminalView { self.apply_grid_size(next, cx); } + pub fn pinch_font_size(&self, cx: &App) -> TerminalFontSize { + let _ = cx; + self.font_size + } + + pub fn is_pinch_active(&self, cx: &App) -> bool { + let _ = cx; + self.pinch_claimed + } + + fn reject_degenerate_cell(cell_width: Pixels, line_height: Pixels) -> bool { + let width = (cell_width / px(1.0)) as f32; + let height = (line_height / px(1.0)) as f32; + !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 + } + + fn apply_font_size_step( + &mut self, + font_size: TerminalFontSize, + window: &mut Window, + cx: &mut Context, + ) { + self.font_size = font_size; + let line_height = font_size.line_height(); + let cell_width = Self::measure_cell_width(window, line_height); + if Self::reject_degenerate_cell(cell_width, line_height) { + return; + } + let next = Self::compute_grid_size_with_metrics(self.viewport, cell_width, line_height); + self.apply_grid_size(next, cx); + } + + fn handle_terminal_pinch( + &mut self, + event: &PinchEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event.phase { + TouchPhase::Started => { + self.signal_interaction(cx); + if self.terminal.read(cx).selection_active() { + self.pinch_claimed = false; + return; + } + window.prevent_default(); + self.pinch_claimed = true; + self.pinch_start_font_size = self.font_size; + self.zoom_ratchet.set_font_size(self.font_size); + self.zoom_ratchet.begin(); + } + TouchPhase::Moved => { + if !self.pinch_claimed { + return; + } + if self.terminal.read(cx).selection_active() { + self.zoom_ratchet.end(); + return; + } + let steps = self.zoom_ratchet.push_delta(event.delta); + for step in steps { + self.apply_font_size_step(step.to, window, cx); + cx.emit(TerminalEvent::PinchStepHaptic); + } + } + TouchPhase::Ended => { + if !self.pinch_claimed { + return; + } + self.pinch_claimed = false; + let settled = self.zoom_ratchet.font_size(); + let changed = settled != self.pinch_start_font_size; + self.zoom_ratchet.end(); + if changed { + cx.emit(TerminalEvent::PinchSettled { + font_size: settled.as_u8(), + }); + } + } + } + } + fn apply_grid_size(&mut self, next: TerminalGridSize, cx: &mut Context) { let size = self.terminal.read(cx).size(); let changed = size.columns != next.columns @@ -526,6 +663,7 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { + self.signal_interaction(cx); let position = event.position(); let hyperlink = self.terminal.read(cx).hyperlink_at( @@ -570,6 +708,7 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { + self.signal_interaction(cx); let position = event.down.position; window.prevent_default(); @@ -619,8 +758,30 @@ impl TerminalView { terminal.clear_selection_range(); terminal.paste_text(text); }); + self.signal_user_outbound_input(cx); cx.notify(); } + + fn signal_interaction(&mut self, cx: &mut Context) { + if self.interaction_signaled { + return; + } + self.interaction_signaled = true; + cx.emit(TerminalEvent::InteractionStarted); + } + + pub fn signal_user_outbound_input(&mut self, cx: &mut Context) { + if self.outbound_input_signaled { + return; + } + self.outbound_input_signaled = true; + cx.emit(TerminalEvent::UserOutboundInput); + } + + pub fn reset_reclaim_epoch(&mut self) { + self.interaction_signaled = false; + self.outbound_input_signaled = false; + } } impl EventEmitter for TerminalView {} @@ -650,6 +811,9 @@ impl Render for TerminalView { .bg(rgb(self.terminal_theme.background)) .track_focus(&focus_handle) .manual_focus() + .on_pinch(cx.listener(|this, event: &PinchEvent, window, cx| { + this.handle_terminal_pinch(event, window, cx); + })) .on_press(cx.listener(|this, event: &PressEvent, window, cx| { this.handle_terminal_press(event, window, cx); })) @@ -657,6 +821,7 @@ impl Render for TerminalView { this.handle_terminal_long_press(event, window, cx); })) .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| { + this.signal_interaction(cx); let previous_display_offset = this.display_offset(cx); if this.should_ignore_touch_scroll(event) { cx.notify(); @@ -815,7 +980,7 @@ impl Render for TerminalView { #[cfg(test)] mod tests { - use super::{TerminalView, keyboard_content_offset_px}; + use super::{TERMINAL_LINE_HEIGHT, TerminalFontSize, TerminalView, keyboard_content_offset_px}; use std::{path::Path, time::Duration}; use crate::terminal::{Terminal, TerminalContent, TerminalEvent, TerminalHyperlinkTarget}; @@ -1005,6 +1170,34 @@ mod tests { }); } + fn pinch_terminal( + window: WindowHandle, + cx: &mut TestAppContext, + phase: TouchPhase, + delta: f32, + ) { + let mut window_cx = VisualTestContext::from_window(*window, cx); + window_cx.simulate_event(gpui::PinchEvent { + position: point(px(160.0), px(120.0)), + delta, + modifiers: Modifiers::default(), + phase, + }); + } + + fn pinch_enter_sequence( + window: WindowHandle, + cx: &mut TestAppContext, + deltas: &[f32], + ) { + pinch_terminal(window, cx, TouchPhase::Started, 0.0); + cx.run_until_parked(); + for delta in deltas { + pinch_terminal(window, cx, TouchPhase::Moved, *delta); + cx.run_until_parked(); + } + } + fn fill_terminal_history(window: WindowHandle, cx: &mut TestAppContext) { window .update(cx, |terminal_view, _window, cx| { @@ -1065,6 +1258,25 @@ mod tests { } target => panic!("expected file hyperlink, got {target:?}"), }, + Some(TerminalEvent::InteractionStarted) => { + match events.next().now_or_never().flatten() { + Some(TerminalEvent::OpenHyperlink(hyperlink)) => match hyperlink.target { + TerminalHyperlinkTarget::File { + path, + relative_path, + line, + column, + } => { + assert_eq!(Path::new(&path), Path::new("/repo/sub/src/main.rs")); + assert_eq!(Path::new(&relative_path), Path::new("src/main.rs")); + assert_eq!(line, Some(12)); + assert_eq!(column, Some(3)); + } + target => panic!("expected file hyperlink, got {target:?}"), + }, + event => panic!("expected hyperlink event, got {event:?}"), + } + } event => panic!("expected hyperlink event, got {event:?}"), } cx.quit(); @@ -1300,6 +1512,14 @@ mod tests { Some(TerminalEvent::NativePasteMenuRequested { position: actual }) => { assert_eq!(actual, position); } + Some(TerminalEvent::InteractionStarted) => { + match events.next().now_or_never().flatten() { + Some(TerminalEvent::NativePasteMenuRequested { position: actual }) => { + assert_eq!(actual, position); + } + event => panic!("expected native paste menu request, got {event:?}"), + } + } event => panic!("expected native paste menu request, got {event:?}"), } window @@ -1516,6 +1736,14 @@ mod tests { Some(TerminalEvent::ScrollbackPositionChanged { display_offset, .. }) => { assert!(display_offset > 0); } + Some(TerminalEvent::InteractionStarted) => { + match events.next().now_or_never().flatten() { + Some(TerminalEvent::ScrollbackPositionChanged { display_offset, .. }) => { + assert!(display_offset > 0); + } + event => panic!("expected synchronous scrollback event, got {event:?}"), + } + } event => panic!("expected synchronous scrollback event, got {event:?}"), } cx.quit(); @@ -1740,6 +1968,22 @@ mod tests { assert_eq!(keyboard_offset_for_content(&terminal.content()), 0.0); } + #[test] + fn baseline_terminal_font_metrics_preserve_12_over_16_ratio() { + assert_eq!(crate::TERMINAL_FONT_SIZE, px(12.0)); + assert_eq!(px(TERMINAL_LINE_HEIGHT), px(16.0)); + assert_eq!(crate::TERMINAL_FONT_SIZE / px(TERMINAL_LINE_HEIGHT), 0.75); + + let default_font_size = TerminalFontSize::DEFAULT; + assert_eq!(default_font_size.as_u8(), 12); + assert_eq!(default_font_size.logical_units(), px(12.0)); + assert_eq!(default_font_size.line_height(), px(16.0)); + assert_eq!( + default_font_size.logical_units() / default_font_size.line_height(), + 0.75 + ); + } + #[test] fn keyboard_offset_ignores_manual_scrollback_and_alt_screen() { let mut content = @@ -1752,4 +1996,176 @@ mod tests { content.mode = TermMode::ALT_SCREEN; assert_eq!(keyboard_offset_for_content(&content), 0.0); } + + #[test] + fn claimed_pinch_produces_crisp_step_and_haptic_with_resize() { + let mut cx = TestAppContext::single(); + let window = open_terminal_window(&mut cx); + cx.run_until_parked(); + + let root = window.root(&mut cx).unwrap(); + let mut events = cx.events(&root); + pinch_enter_sequence(window, &mut cx, &[0.04, 0.04, 0.03]); + + let font_size = window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.pinch_font_size(cx) + }) + .unwrap(); + assert_eq!(font_size, TerminalFontSize::new(13)); + assert_eq!(font_size.logical_units(), px(13.0)); + + let mut saw_haptic = false; + let mut saw_resize = false; + while let Some(event) = events.next().now_or_never().flatten() { + match event { + TerminalEvent::PinchStepHaptic => saw_haptic = true, + TerminalEvent::RequestResize { .. } => saw_resize = true, + _ => {} + } + } + assert!(saw_haptic); + assert!(saw_resize); + cx.quit(); + } + + #[test] + fn pinch_emits_zero_pty_bytes_in_normal_alt_screen_and_mouse_modes() { + for with_mouse_tracking in [false, true] { + let mut cx = TestAppContext::single(); + let window = open_terminal_window(&mut cx); + cx.run_until_parked(); + let mut input_rx = if with_mouse_tracking { + attach_mouse_tracking_channel(window, &mut cx) + } else { + attach_terminal_channel(window, &mut cx).0 + }; + window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.terminal.update(cx, |terminal, _| { + terminal.advance_bytes(b"\x1b[?1049h"); + }); + }) + .unwrap(); + cx.run_until_parked(); + + pinch_enter_sequence(window, &mut cx, &[0.04, 0.04, 0.03]); + pinch_terminal(window, &mut cx, TouchPhase::Ended, 0.0); + cx.run_until_parked(); + + assert!(input_rx.try_recv().is_err()); + let font_size = window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.pinch_font_size(cx) + }) + .unwrap(); + assert_eq!(font_size, TerminalFontSize::new(13)); + cx.quit(); + } + } + + #[test] + fn active_selection_blocks_pinch_claim_and_mid_pinch_stops_zoom() { + let mut cx = TestAppContext::single(); + let window = open_terminal_window(&mut cx); + cx.run_until_parked(); + window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.terminal.update(cx, |terminal, _| { + terminal.set_selection_range(0..4); + }); + }) + .unwrap(); + + pinch_enter_sequence(window, &mut cx, &[0.11]); + let font_size = window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.pinch_font_size(cx) + }) + .unwrap(); + assert_eq!(font_size, TerminalFontSize::DEFAULT); + + window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.terminal.update(cx, |terminal, _| { + terminal.clear_selection_range(); + }); + }) + .unwrap(); + pinch_enter_sequence(window, &mut cx, &[0.06]); + window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.terminal.update(cx, |terminal, _| { + terminal.set_selection_range(0..4); + }); + }) + .unwrap(); + pinch_terminal(window, &mut cx, TouchPhase::Moved, 0.11); + cx.run_until_parked(); + + let font_size = window + .update(&mut cx, |terminal_view, _, cx| { + let selection_active = terminal_view.terminal.read(cx).selection_active(); + assert!(selection_active); + terminal_view.pinch_font_size(cx) + }) + .unwrap(); + assert_eq!(font_size, TerminalFontSize::DEFAULT); + cx.quit(); + } + + #[test] + fn pinch_preserves_bottom_follow_and_clamps_scrollback() { + let mut cx = TestAppContext::single(); + let window = open_terminal_window(&mut cx); + cx.run_until_parked(); + fill_terminal_history(window, &mut cx); + window + .update(&mut cx, |terminal_view, _, cx| { + terminal_view.scroll_to_bottom(cx); + }) + .unwrap(); + cx.run_until_parked(); + + pinch_enter_sequence(window, &mut cx, &[0.04, 0.04, 0.03]); + pinch_terminal(window, &mut cx, TouchPhase::Ended, 0.0); + cx.run_until_parked(); + + window + .update(&mut cx, |terminal_view, _, cx| { + let terminal = terminal_view.terminal.read(cx); + assert_eq!(terminal.display_offset(), 0); + assert!( + terminal.display_offset() <= terminal.history_size(), + "scrollback offset must remain within history bounds" + ); + assert_eq!(terminal_view.pinch_font_size(cx), TerminalFontSize::new(13)); + }) + .unwrap(); + cx.quit(); + } + + #[test] + fn pinch_settles_exactly_once_on_end_without_double_persist() { + let mut cx = TestAppContext::single(); + let window = open_terminal_window(&mut cx); + cx.run_until_parked(); + + let root = window.root(&mut cx).unwrap(); + let mut events = cx.events(&root); + pinch_enter_sequence(window, &mut cx, &[0.04, 0.04, 0.03]); + pinch_terminal(window, &mut cx, TouchPhase::Ended, 0.0); + cx.run_until_parked(); + pinch_terminal(window, &mut cx, TouchPhase::Moved, 0.50); + cx.run_until_parked(); + + let mut settled = Vec::new(); + while let Some(event) = events.next().now_or_never().flatten() { + if let TerminalEvent::PinchSettled { font_size } = event { + settled.push(font_size); + } + } + assert_eq!(settled, vec![13]); + cx.quit(); + } } diff --git a/crates/zedra-terminal/src/zoom_ratchet.rs b/crates/zedra-terminal/src/zoom_ratchet.rs new file mode 100644 index 00000000..84e3d2b2 --- /dev/null +++ b/crates/zedra-terminal/src/zoom_ratchet.rs @@ -0,0 +1,340 @@ +use gpui::Pixels; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct TerminalFontSize(u8); + +impl TerminalFontSize { + pub const DEFAULT: Self = Self(12); + pub const MIN: Self = Self(9); + pub const MAX: Self = Self(24); + + pub const fn new(value: u8) -> Self { + let value = if value < Self::MIN.0 { + Self::MIN.0 + } else if value > Self::MAX.0 { + Self::MAX.0 + } else { + value + }; + Self(value) + } + + pub const fn as_u8(self) -> u8 { + self.0 + } + + pub const fn line_height_f32(self) -> f32 { + self.0 as f32 / 0.75 + } + + pub fn logical_units(self) -> Pixels { + gpui::px(self.0 as f32) + } + + pub fn line_height(self) -> Pixels { + gpui::px(self.line_height_f32()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ZoomStep { + pub from: TerminalFontSize, + pub to: TerminalFontSize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ZoomDirection { + Increase, + Decrease, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TerminalZoomRatchet { + font_size: TerminalFontSize, + active: bool, + direction: Option, + remainder: f32, + reversal_debt: f32, +} + +impl Default for TerminalZoomRatchet { + fn default() -> Self { + Self::new(TerminalFontSize::DEFAULT) + } +} + +impl TerminalZoomRatchet { + pub const STEP_THRESHOLD: f32 = 0.10; + pub const REVERSAL_DEADBAND: f32 = 0.03; + + pub const fn new(font_size: TerminalFontSize) -> Self { + Self { + font_size, + active: false, + direction: None, + remainder: 0.0, + reversal_debt: 0.0, + } + } + + pub const fn font_size(self) -> TerminalFontSize { + self.font_size + } + + pub const fn remainder(self) -> f32 { + self.remainder + } + + pub const fn is_active(self) -> bool { + self.active + } + + pub fn set_font_size(&mut self, font_size: TerminalFontSize) { + self.font_size = font_size; + self.remainder = 0.0; + self.direction = None; + self.reversal_debt = 0.0; + } + + pub fn begin(&mut self) { + self.active = true; + self.remainder = 0.0; + self.direction = None; + self.reversal_debt = 0.0; + } + + pub fn end(&mut self) { + self.active = false; + self.remainder = 0.0; + self.direction = None; + self.reversal_debt = 0.0; + } + + pub fn cancel(&mut self) { + self.end(); + } + + pub fn reset(&mut self) { + self.active = false; + self.remainder = 0.0; + self.direction = None; + self.reversal_debt = 0.0; + } + + pub fn push_delta(&mut self, delta: f32) -> Vec { + if !self.active || should_reject(delta) { + return Vec::new(); + } + ingest_delta(self, delta) + } + + pub fn push_scale(&mut self, scale: f32) -> Vec { + if scale <= 0.0 || !scale.is_finite() { + return Vec::new(); + } + self.push_delta(scale - 1.0) + } +} + +fn should_reject(delta: f32) -> bool { + !delta.is_finite() || delta == 0.0 +} + +fn at_clamp_limits_outward(font_size: TerminalFontSize, direction: ZoomDirection) -> bool { + matches!(direction, ZoomDirection::Increase if font_size == TerminalFontSize::MAX) + || matches!(direction, ZoomDirection::Decrease if font_size == TerminalFontSize::MIN) +} + +fn ingest_delta(ratchet: &mut TerminalZoomRatchet, delta: f32) -> Vec { + let direction = if delta.is_sign_positive() { + ZoomDirection::Increase + } else { + ZoomDirection::Decrease + }; + if at_clamp_limits_outward(ratchet.font_size, direction) { + ratchet.remainder = 0.0; + ratchet.direction = None; + ratchet.reversal_debt = 0.0; + return Vec::new(); + } + if let Some(active_direction) = ratchet.direction { + if active_direction != direction { + ratchet.reversal_debt += delta.abs(); + if ratchet.reversal_debt <= TerminalZoomRatchet::REVERSAL_DEADBAND { + return Vec::new(); + } + let excess = ratchet.reversal_debt - TerminalZoomRatchet::REVERSAL_DEADBAND; + ratchet.direction = Some(direction); + ratchet.remainder = excess; + ratchet.reversal_debt = 0.0; + } else { + if ratchet.reversal_debt != 0.0 { + ratchet.reversal_debt = 0.0; + } + ratchet.remainder += delta.abs(); + } + } else { + ratchet.direction = Some(direction); + ratchet.remainder = delta.abs(); + } + accumulate_steps(ratchet) +} + +fn accumulate_steps(ratchet: &mut TerminalZoomRatchet) -> Vec { + let direction = match ratchet.direction { + Some(direction) => direction, + None => return Vec::new(), + }; + let requested = (ratchet.remainder / TerminalZoomRatchet::STEP_THRESHOLD).floor() as usize; + if requested == 0 { + return Vec::new(); + } + let available = match direction { + ZoomDirection::Increase => { + (TerminalFontSize::MAX.as_u8() - ratchet.font_size.as_u8()) as usize + } + ZoomDirection::Decrease => { + (ratchet.font_size.as_u8() - TerminalFontSize::MIN.as_u8()) as usize + } + }; + if available == 0 { + ratchet.remainder = 0.0; + return Vec::new(); + } + let accepted = requested.min(available); + ratchet.remainder -= accepted as f32 * TerminalZoomRatchet::STEP_THRESHOLD; + if accepted < requested { + ratchet.remainder = 0.0; + } + let mut steps = Vec::with_capacity(accepted); + for _ in 0..accepted { + let from = ratchet.font_size; + let to = TerminalFontSize::new(match direction { + ZoomDirection::Increase => from.as_u8() + 1, + ZoomDirection::Decrease => from.as_u8() - 1, + }); + ratchet.font_size = to; + steps.push(ZoomStep { from, to }); + if to == TerminalFontSize::MAX || to == TerminalFontSize::MIN { + ratchet.remainder = 0.0; + ratchet.direction = None; + ratchet.reversal_debt = 0.0; + } + } + steps +} + +#[cfg(test)] +mod tests { + use super::{TerminalFontSize, TerminalZoomRatchet}; + + #[test] + fn zoom_ratchet_trace_prints_accepted_step_sequence_and_final_font_size() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + println!( + "[ratchet-trace] initial font size: {:?}", + ratchet.font_size() + ); + let s1 = ratchet.push_delta(0.04); + println!("[ratchet-trace] push_delta(0.04) -> steps: {:?}", s1); + let s2 = ratchet.push_delta(0.04); + println!("[ratchet-trace] push_delta(0.04) -> steps: {:?}", s2); + let s3 = ratchet.push_delta(0.03); + println!("[ratchet-trace] push_delta(0.03) -> steps: {:?}", s3); + println!("[ratchet-trace] final font size: {:?}", ratchet.font_size()); + println!( + "[ratchet-trace] final line height: {:?}", + ratchet.font_size().line_height() + ); + assert_eq!(s3.len(), 1); + assert_eq!(ratchet.font_size(), TerminalFontSize::new(13)); + } + + #[test] + fn failing_first_sequence_004_004_003_emits_exactly_one_12_to_13_step() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + assert!(ratchet.push_delta(0.04).is_empty()); + assert!(ratchet.push_delta(0.04).is_empty()); + let steps = ratchet.push_delta(0.03); + assert_eq!(steps.len(), 1); + let step = steps[0]; + assert_eq!(step.from, TerminalFontSize::new(12)); + assert_eq!(step.to, TerminalFontSize::new(13)); + assert_eq!(ratchet.font_size(), TerminalFontSize::new(13)); + } + + #[test] + fn reversal_deadband_discards_remainder_and_does_not_oscillate() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + assert!(ratchet.push_delta(0.06).is_empty()); + let neg_within_deadband = ratchet.push_delta(-0.02); + assert!(neg_within_deadband.is_empty()); + assert!(ratchet.push_delta(-0.02).is_empty()); + assert_eq!(ratchet.font_size(), TerminalFontSize::DEFAULT); + } + + #[test] + fn reversal_beyond_deadband_applies_excess_not_prior_remainder() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + assert!(ratchet.push_delta(0.08).is_empty()); + let steps = ratchet.push_delta(-0.14); + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].to, TerminalFontSize::new(11)); + } + + #[test] + fn large_finite_delta_emits_bounded_steps_and_preserves_overshoot() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + let steps = ratchet.push_delta(0.27); + assert_eq!(steps.len(), 2); + assert_eq!(ratchet.font_size(), TerminalFontSize::new(14)); + assert!((ratchet.remainder() - 0.07).abs() < 1e-6); + } + + #[test] + fn upper_and_lower_clamp_stop_outward_movement_and_clear_debt() { + let mut upper = TerminalZoomRatchet::new(TerminalFontSize::MAX); + upper.begin(); + assert!(upper.push_delta(0.50).is_empty()); + assert_eq!(upper.font_size(), TerminalFontSize::MAX); + assert_eq!(upper.remainder(), 0.0); + let down = upper.push_delta(-0.11); + assert_eq!(down.len(), 1); + assert_eq!(upper.font_size(), TerminalFontSize::new(23)); + + let mut lower = TerminalZoomRatchet::new(TerminalFontSize::MIN); + lower.begin(); + assert!(lower.push_delta(-0.50).is_empty()); + assert_eq!(lower.font_size(), TerminalFontSize::MIN); + let up = lower.push_delta(0.11); + assert_eq!(up.len(), 1); + assert_eq!(lower.font_size(), TerminalFontSize::new(10)); + } + + #[test] + fn rejects_nan_and_infinities_without_state_poison() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + assert!(ratchet.push_delta(f32::NAN).is_empty()); + assert!(ratchet.push_delta(f32::INFINITY).is_empty()); + assert!(ratchet.push_delta(f32::NEG_INFINITY).is_empty()); + assert!(ratchet.push_scale(f32::NAN).is_empty()); + assert!(ratchet.push_scale(0.0).is_empty()); + assert_eq!(ratchet.font_size(), TerminalFontSize::DEFAULT); + assert!(ratchet.push_delta(0.11).len() == 1); + } + + #[test] + fn stale_move_after_end_emits_no_effects() { + let mut ratchet = TerminalZoomRatchet::default(); + ratchet.begin(); + ratchet.end(); + assert!(ratchet.push_delta(0.50).is_empty()); + assert_eq!(ratchet.font_size(), TerminalFontSize::DEFAULT); + } +} From b67adecdbe18eddabe92e88cda1f035c8ccbda23 Mon Sep 17 00:00:00 2001 From: longdd Date: Tue, 15 Sep 2026 03:10:11 +0700 Subject: [PATCH 02/10] chore(vendor): update gpui submodule for ios pinch arbitration Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- vendor/zed | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/zed b/vendor/zed index 5955915c..21ab017a 160000 --- a/vendor/zed +++ b/vendor/zed @@ -1 +1 @@ -Subproject commit 5955915c572ea9336d9648a2d286b399298fe5ff +Subproject commit 21ab017a849f1b92dacc14361d315c99412ea4da From 79bb5e34e4bc7af45de308cb4ec870af0b8c8e62 Mon Sep 17 00:00:00 2001 From: longdd Date: Tue, 15 Sep 2026 03:10:18 +0700 Subject: [PATCH 03/10] feat(app): persist terminal font size Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/zedra/src/settings.rs | 444 ++++++++++++++++++++++++++++++++++- 1 file changed, 441 insertions(+), 3 deletions(-) diff --git a/crates/zedra/src/settings.rs b/crates/zedra/src/settings.rs index 3ea5d6af..162f3d2f 100644 --- a/crates/zedra/src/settings.rs +++ b/crates/zedra/src/settings.rs @@ -1,4 +1,7 @@ +// allow: SIZE_OK — app settings schema, preference persistence, and regression test suite +use std::collections::BTreeMap; use std::path::PathBuf; +use std::sync::Mutex; use std::sync::atomic::{AtomicI8, Ordering}; use gpui::{App, Context, Entity, EventEmitter, Global, WeakEntity}; @@ -10,6 +13,42 @@ use crate::theme::{ThemeBundle, ThemePreference}; const STORE_DIR: &str = "zedra"; const SETTINGS_FILE: &str = "settings.json"; +pub const TERMINAL_FONT_SIZE_DEFAULT: u8 = 12; +pub const TERMINAL_FONT_SIZE_MIN: u8 = 9; +pub const TERMINAL_FONT_SIZE_MAX: u8 = 24; + +pub const fn normalize_terminal_font_size(value: u8) -> u8 { + if value < TERMINAL_FONT_SIZE_MIN { + TERMINAL_FONT_SIZE_MIN + } else if value > TERMINAL_FONT_SIZE_MAX { + TERMINAL_FONT_SIZE_MAX + } else { + value + } +} + +fn deserialize_terminal_font_size<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value: Option = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(None); + }; + match value { + serde_json::Value::Number(n) if n.is_i64() => Ok(n.as_i64()), + serde_json::Value::Number(n) if n.is_u64() => { + let v = n.as_u64().unwrap_or(0); + if v > i64::MAX as u64 { + Ok(None) + } else { + Ok(Some(v as i64)) + } + } + _ => Ok(None), + } +} + #[derive(Clone, Debug, Default, Serialize, Deserialize)] struct AppSettings { /// Set when the user picks a theme in Settings; `None` follows the system on next launch. @@ -29,6 +68,30 @@ struct AppSettings { /// `None`/absent = disabled (default-off). #[serde(default, skip_serializing_if = "Option::is_none")] extended_keypad: Option, + #[serde( + default, + deserialize_with = "deserialize_terminal_font_size", + skip_serializing_if = "Option::is_none" + )] + terminal_font_size: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +impl AppSettings { + fn sanitize_extra(&mut self) { + const KNOWN_KEYS: &[&str] = &[ + "theme_preference", + "telemetry_enabled", + "droplet_enabled", + "key_bar_always_visible", + "extended_keypad", + "terminal_font_size", + ]; + for key in KNOWN_KEYS { + self.extra.remove(*key); + } + } } pub enum ThemeStateEvent { @@ -131,7 +194,21 @@ pub fn bundle(cx: &App) -> ThemeBundle { .unwrap_or_else(ThemeBundle::dark) } +#[cfg(test)] +static TEST_DATA_DIRECTORY: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +#[cfg(test)] +fn test_data_directory() -> &'static Mutex> { + TEST_DATA_DIRECTORY.get_or_init(|| Mutex::new(None)) +} + fn data_directory() -> Option { + #[cfg(test)] + if let Some(dir) = test_data_directory().lock().ok().and_then(|g| g.clone()) { + return Some(dir); + } + crate::platform_bridge::bridge() .data_directory() .map(PathBuf::from) @@ -151,15 +228,97 @@ fn read_settings() -> Result { return Ok(AppSettings::default()); } let contents = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; - serde_json::from_str(&contents).map_err(|e| e.to_string()) + let mut settings: AppSettings = serde_json::from_str(&contents).map_err(|e| e.to_string())?; + settings.sanitize_extra(); + Ok(settings) } fn write_settings(settings: &AppSettings) -> Result<(), String> { let path = settings_path().ok_or_else(|| "settings path unavailable".to_string())?; - let contents = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?; + let mut settings = settings.clone(); + settings.sanitize_extra(); + let contents = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; std::fs::write(path, contents).map_err(|e| e.to_string()) } +const fn terminal_font_size_from_raw(raw: Option) -> u8 { + let Some(value) = raw else { + return TERMINAL_FONT_SIZE_DEFAULT; + }; + + let clamped = if value < TERMINAL_FONT_SIZE_MIN as i64 { + TERMINAL_FONT_SIZE_MIN + } else if value > TERMINAL_FONT_SIZE_MAX as i64 { + TERMINAL_FONT_SIZE_MAX + } else { + value as u8 + }; + normalize_terminal_font_size(clamped) +} + +fn read_terminal_font_size_pref(settings: &AppSettings) -> u8 { + terminal_font_size_from_raw(settings.terminal_font_size) +} + +static TERMINAL_FONT_SIZE_CACHE: AtomicI8 = AtomicI8::new(-1); + +pub fn read_terminal_font_size() -> u8 { + match read_settings() { + Ok(settings) => read_terminal_font_size_pref(&settings), + Err(err) => { + info!(err = %err, "settings: using default terminal font size"); + TERMINAL_FONT_SIZE_DEFAULT + } + } +} + +pub fn terminal_font_size() -> u8 { + match TERMINAL_FONT_SIZE_CACHE.load(Ordering::Relaxed) { + -1 => { + let size = read_terminal_font_size(); + TERMINAL_FONT_SIZE_CACHE.store(size as i8, Ordering::Relaxed); + size + } + cached => normalize_terminal_font_size(cached as u8), + } +} + +static FONT_SIZE_LISTENERS: Mutex>> = + Mutex::new(Vec::new()); + +pub fn on_terminal_font_size_changed(callback: impl Fn(u8) + Send + Sync + 'static) { + if let Ok(mut listeners) = FONT_SIZE_LISTENERS.lock() { + listeners.push(Box::new(callback)); + } +} + +pub fn set_terminal_font_size(size: u8) -> bool { + let accepted = normalize_terminal_font_size(size); + let mut settings = read_settings().unwrap_or_default(); + let current = read_terminal_font_size_pref(&settings); + if current == accepted { + TERMINAL_FONT_SIZE_CACHE.store(accepted as i8, Ordering::Relaxed); + return false; + } + TERMINAL_FONT_SIZE_CACHE.store(accepted as i8, Ordering::Relaxed); + settings.terminal_font_size = Some(accepted as i64); + if let Err(err) = write_settings(&settings) { + warn!(err = %err, "settings: failed to save terminal font size"); + return false; + } + if let Ok(listeners) = FONT_SIZE_LISTENERS.lock() { + for listener in listeners.iter() { + listener(accepted); + } + } + true +} + +#[cfg(test)] +pub(crate) fn reset_terminal_font_size_cache_for_tests() { + TERMINAL_FONT_SIZE_CACHE.store(-1, Ordering::Relaxed); +} + /// Whether anonymous telemetry is enabled. Absent/unreadable settings default to /// enabled (opt-out model), matching the persisted `None` case. pub fn read_telemetry_enabled() -> bool { @@ -271,9 +430,55 @@ pub fn set_key_bar_always_visible(enabled: bool) { #[cfg(test)] mod tests { - use super::ThemeState; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + use super::*; use crate::theme::{ThemeBundle, ThemePalette, ThemePreference}; + static TEST_SERIAL_LOCK: Mutex<()> = Mutex::new(()); + + struct TestDirectoryScope { + dir: PathBuf, + _lock: std::sync::MutexGuard<'static, ()>, + } + + impl TestDirectoryScope { + fn new(name: &str) -> Self { + let lock = TEST_SERIAL_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "zedra_settings_{}_{}_{}", + name, + std::process::id(), + id + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create test temp dir"); + *test_data_directory() + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(dir.clone()); + reset_terminal_font_size_cache_for_tests(); + Self { dir, _lock: lock } + } + + fn settings_file(&self) -> PathBuf { + self.dir.join(STORE_DIR).join(SETTINGS_FILE) + } + } + + impl Drop for TestDirectoryScope { + fn drop(&mut self) { + *test_data_directory() + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; + reset_terminal_font_size_cache_for_tests(); + let _ = std::fs::remove_dir_all(&self.dir); + } + } + #[test] fn default_preference_is_dark() { assert_eq!(ThemePreference::default(), ThemePreference::Dark); @@ -294,4 +499,237 @@ mod tests { ThemePalette::light().bg_primary ); } + + #[test] + fn existing_optional_settings_round_trip_current_schema() { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "theme_preference": "light", + "telemetry_enabled": false, + "droplet_enabled": true, + "key_bar_always_visible": true, + "extended_keypad": true, + })) + .expect("known optional settings should load"); + + let serialized = + serde_json::to_value(settings).expect("known optional settings should save"); + assert_eq!(serialized["theme_preference"], "light"); + assert_eq!(serialized["telemetry_enabled"], false); + assert_eq!(serialized["droplet_enabled"], true); + assert_eq!(serialized["key_bar_always_visible"], true); + assert_eq!(serialized["extended_keypad"], true); + } + + #[test] + fn terminal_font_size_defaults_to_12_when_missing() { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "telemetry_enabled": true, + })) + .expect("settings missing font size should load"); + assert_eq!(read_terminal_font_size_pref(&settings), 12); + } + + #[test] + fn terminal_font_size_clamps_below_min_to_9() { + for below_min in [-100, -1, 0, 5, 8] { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "terminal_font_size": below_min, + })) + .expect("negative/small integers should parse without error"); + assert_eq!(read_terminal_font_size_pref(&settings), 9); + } + } + + #[test] + fn terminal_font_size_clamps_above_max_to_24() { + for above_max in [25, 30, 100, 1000] { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "terminal_font_size": above_max, + })) + .expect("oversized integers should parse without error"); + assert_eq!(read_terminal_font_size_pref(&settings), 24); + } + } + + #[test] + fn terminal_font_size_tolerant_deserialization_handles_malformed_values() { + let malformed_cases = vec![ + serde_json::json!("large"), + serde_json::json!("15"), + serde_json::json!({"custom_size": 15}), + serde_json::json!([12, 14]), + serde_json::json!(true), + serde_json::json!(false), + serde_json::json!(null), + serde_json::json!(15.5), + ]; + + for malformed in malformed_cases { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "telemetry_enabled": false, + "terminal_font_size": malformed, + "unknown_flag": "preserved", + })) + .expect("malformed font size must never fail entire settings parse"); + + assert_eq!(settings.telemetry_enabled, Some(false)); + assert_eq!(read_terminal_font_size_pref(&settings), 12); + assert_eq!( + settings.extra.get("unknown_flag"), + Some(&serde_json::json!("preserved")) + ); + } + } + + #[test] + fn terminal_font_size_normal_15_round_trips() { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "terminal_font_size": 15, + })) + .expect("integer font size 15 should parse"); + + assert_eq!(read_terminal_font_size_pref(&settings), 15); + let serialized = serde_json::to_value(settings).expect("save settings"); + assert_eq!(serialized["terminal_font_size"], 15); + } + + #[test] + fn unknown_keys_preserved_semantically_including_nested_structures() { + let original_json = serde_json::json!({ + "telemetry_enabled": false, + "custom_number": 42, + "custom_text": "hello", + "nested_dict": { + "inner_list": [1, "two", {"deep": true}], + "score": 9.75 + } + }); + + let mut settings: AppSettings = + serde_json::from_value(original_json.clone()).expect("parse settings"); + settings.terminal_font_size = Some(15); + let serialized = serde_json::to_value(settings).expect("serialize settings"); + + assert_eq!(serialized["telemetry_enabled"], false); + assert_eq!(serialized["terminal_font_size"], 15); + assert_eq!(serialized["custom_number"], 42); + assert_eq!(serialized["custom_text"], "hello"); + assert_eq!( + serialized["nested_dict"], + serde_json::json!({ + "inner_list": [1, "two", {"deep": true}], + "score": 9.75 + }) + ); + } + + #[test] + fn flattened_map_cannot_shadow_known_fields() { + let mut settings = AppSettings { + theme_preference: Some(ThemePreference::Light), + telemetry_enabled: Some(true), + terminal_font_size: Some(16), + extra: BTreeMap::new(), + ..Default::default() + }; + settings + .extra + .insert("theme_preference".to_string(), serde_json::json!("dark")); + settings + .extra + .insert("terminal_font_size".to_string(), serde_json::json!(999)); + settings.sanitize_extra(); + + let serialized = serde_json::to_value(&settings).expect("serialize settings"); + assert_eq!(serialized["theme_preference"], "light"); + assert_eq!(serialized["terminal_font_size"], 16); + } + + #[test] + fn setter_writes_only_when_accepted_integer_changes() { + let scope = TestDirectoryScope::new("setter_changed_value"); + assert_eq!(terminal_font_size(), 12); + assert_eq!(set_terminal_font_size(12), false); + assert!(!scope.settings_file().exists()); + + assert_eq!(set_terminal_font_size(15), true); + assert_eq!(terminal_font_size(), 15); + assert!(scope.settings_file().exists()); + let on_disk_15: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(scope.settings_file()).unwrap()).unwrap(); + assert_eq!(on_disk_15["terminal_font_size"], 15); + + assert_eq!(set_terminal_font_size(15), false); + + assert_eq!(set_terminal_font_size(30), true); + assert_eq!(terminal_font_size(), 24); + let on_disk_24: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(scope.settings_file()).unwrap()).unwrap(); + assert_eq!(on_disk_24["terminal_font_size"], 24); + + assert_eq!(set_terminal_font_size(24), false); + assert_eq!(set_terminal_font_size(25), false); + + assert_eq!(set_terminal_font_size(5), true); + assert_eq!(terminal_font_size(), 9); + let on_disk_9: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(scope.settings_file()).unwrap()).unwrap(); + assert_eq!(on_disk_9["terminal_font_size"], 9); + + assert_eq!(set_terminal_font_size(9), false); + assert_eq!(set_terminal_font_size(7), false); + } + + #[test] + fn listener_notified_on_font_size_change() { + let _scope = TestDirectoryScope::new("listener_notify"); + let notified = Arc::new(AtomicUsize::new(0)); + let notified_clone = notified.clone(); + on_terminal_font_size_changed(move |size| { + notified_clone.store(size as usize, Ordering::Relaxed); + }); + + assert_eq!(set_terminal_font_size(18), true); + assert_eq!(notified.load(Ordering::Relaxed), 18); + } + + #[test] + fn manual_qa_channel_with_temp_settings_json() { + let scope = TestDirectoryScope::new("manual_qa"); + let initial_json = serde_json::json!({ + "theme_preference": "light", + "telemetry_enabled": false, + "terminal_font_size": {"malformed": true, "items": [1, 2]}, + "unknown_feature": { + "metadata": "alpha", + "nested_items": ["x", "y", {"deep_flag": true}] + } + }); + std::fs::create_dir_all(scope.settings_file().parent().unwrap()).unwrap(); + std::fs::write( + scope.settings_file(), + serde_json::to_string_pretty(&initial_json).unwrap(), + ) + .unwrap(); + + assert_eq!(read_terminal_font_size(), 12); + assert_eq!(terminal_font_size(), 12); + + assert_eq!(set_terminal_font_size(15), true); + assert_eq!(terminal_font_size(), 15); + + let final_content = std::fs::read_to_string(scope.settings_file()).unwrap(); + let final_value: serde_json::Value = serde_json::from_str(&final_content).unwrap(); + + assert_eq!(final_value["theme_preference"], "light"); + assert_eq!(final_value["telemetry_enabled"], false); + assert_eq!(final_value["terminal_font_size"], 15); + assert_eq!( + final_value["unknown_feature"], + serde_json::json!({ + "metadata": "alpha", + "nested_items": ["x", "y", {"deep_flag": true}] + }) + ); + } } From b10791342be8a03efe759df6bd20964733cb9606 Mon Sep 17 00:00:00 2001 From: longdd Date: Tue, 15 Sep 2026 03:10:26 +0700 Subject: [PATCH 04/10] fix(app): converge remote terminal resize state Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/zedra/src/lib.rs | 1 + .../zedra/src/terminal_resize_coordinator.rs | 719 ++++++++++++++++++ 2 files changed, 720 insertions(+) create mode 100644 crates/zedra/src/terminal_resize_coordinator.rs diff --git a/crates/zedra/src/lib.rs b/crates/zedra/src/lib.rs index 70f26783..fc2da0ae 100644 --- a/crates/zedra/src/lib.rs +++ b/crates/zedra/src/lib.rs @@ -44,6 +44,7 @@ pub mod sheet_demo_view; pub mod sheet_host_view; pub mod terminal_card; pub mod terminal_panel; +pub mod terminal_resize_coordinator; pub mod terminal_state; pub mod transport_badge; diff --git a/crates/zedra/src/terminal_resize_coordinator.rs b/crates/zedra/src/terminal_resize_coordinator.rs new file mode 100644 index 00000000..1266dee2 --- /dev/null +++ b/crates/zedra/src/terminal_resize_coordinator.rs @@ -0,0 +1,719 @@ +use std::{fmt, num::NonZeroU16}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalGeometry { + columns: NonZeroU16, + rows: NonZeroU16, +} + +impl TerminalGeometry { + pub fn new(columns: u16, rows: u16) -> Result { + let columns = NonZeroU16::new(columns).ok_or(GeometryError::ZeroColumns)?; + let rows = NonZeroU16::new(rows).ok_or(GeometryError::ZeroRows)?; + Ok(Self { columns, rows }) + } + + pub fn from_usize(columns: usize, rows: usize) -> Result { + let columns = u16::try_from(columns).map_err(|_| GeometryError::ColumnsOverflow)?; + let rows = u16::try_from(rows).map_err(|_| GeometryError::RowsOverflow)?; + Self::new(columns, rows) + } + + pub const fn columns(self) -> u16 { + self.columns.get() + } + + pub const fn rows(self) -> u16 { + self.rows.get() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GeometryError { + ZeroColumns, + ZeroRows, + ColumnsOverflow, + RowsOverflow, +} + +impl fmt::Display for GeometryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::ZeroColumns => "terminal columns must be nonzero", + Self::ZeroRows => "terminal rows must be nonzero", + Self::ColumnsOverflow => "terminal columns exceed u16", + Self::RowsOverflow => "terminal rows exceed u16", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for GeometryError {} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ResizeGeneration(u64); + +impl ResizeGeneration { + pub const INITIAL: Self = Self(0); + + pub const fn value(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ReclaimEpoch(u64); + +impl ReclaimEpoch { + pub const INITIAL: Self = Self(0); + + pub const fn value(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct RequestSequence(u64); + +impl RequestSequence { + pub const INITIAL: Self = Self(0); + + pub const fn value(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResizeReason { + Layout, + PinchStep, + ActivationReclaim, + InteractionReclaim, + PostInputReclaim, + ReconnectReclaim, +} + +impl ResizeReason { + pub const fn is_forced(self) -> bool { + matches!( + self, + Self::ActivationReclaim + | Self::InteractionReclaim + | Self::PostInputReclaim + | Self::ReconnectReclaim + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResizeIntent { + geometry: TerminalGeometry, + reason: ResizeReason, + reclaim_epoch: ReclaimEpoch, + generation: ResizeGeneration, + request_id: RequestSequence, +} + +impl ResizeIntent { + pub const fn geometry(&self) -> TerminalGeometry { + self.geometry + } + + pub const fn reason(&self) -> ResizeReason { + self.reason + } + + pub const fn reclaim_epoch(&self) -> ReclaimEpoch { + self.reclaim_epoch + } + + pub const fn generation(&self) -> ResizeGeneration { + self.generation + } + + pub const fn request_id(&self) -> RequestSequence { + self.request_id + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DeliveryOutcome { + Succeeded, + Failed, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CoordinatorTransition { + abort: Option, + dispatch: Option, +} + +impl CoordinatorTransition { + pub const fn empty() -> Self { + Self { + abort: None, + dispatch: None, + } + } + + pub fn abort_request(&self) -> Option<&ResizeIntent> { + self.abort.as_ref() + } + + pub fn dispatch_request(&self) -> Option<&ResizeIntent> { + self.dispatch.as_ref() + } + + pub fn into_actions(self) -> (Option, Option) { + (self.abort, self.dispatch) + } + + fn dispatch(intent: ResizeIntent) -> Self { + Self { + abort: None, + dispatch: Some(intent), + } + } + + fn abort_and_dispatch(abort: Option, dispatch: ResizeIntent) -> Self { + Self { + abort, + dispatch: Some(dispatch), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CoordinatorError { + GenerationExhausted, + ReclaimEpochExhausted, + RequestSequenceExhausted, +} + +impl fmt::Display for CoordinatorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::GenerationExhausted => "resize generation exhausted", + Self::ReclaimEpochExhausted => "resize reclaim epoch exhausted", + Self::RequestSequenceExhausted => "resize request sequence exhausted", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for CoordinatorError {} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CoordinatorSnapshot { + generation: ResizeGeneration, + desired: Option, + in_flight: Option, + pending: Option, + last_successful_geometry: Option, +} + +impl CoordinatorSnapshot { + pub const fn generation(&self) -> ResizeGeneration { + self.generation + } + + pub fn desired(&self) -> Option<&ResizeIntent> { + self.desired.as_ref() + } + + pub fn in_flight(&self) -> Option<&ResizeIntent> { + self.in_flight.as_ref() + } + + pub fn pending(&self) -> Option<&ResizeIntent> { + self.pending.as_ref() + } + + pub const fn last_successful_geometry(&self) -> Option { + self.last_successful_geometry + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TerminalResizeCoordinator { + generation: ResizeGeneration, + reclaim_epoch: ReclaimEpoch, + request_sequence: RequestSequence, + desired: Option, + in_flight: Option, + pending: Option, + last_successful_geometry: Option, +} + +impl TerminalResizeCoordinator { + pub const fn new() -> Self { + Self { + generation: ResizeGeneration::INITIAL, + reclaim_epoch: ReclaimEpoch::INITIAL, + request_sequence: RequestSequence::INITIAL, + desired: None, + in_flight: None, + pending: None, + last_successful_geometry: None, + } + } + + pub fn snapshot(&self) -> CoordinatorSnapshot { + CoordinatorSnapshot { + generation: self.generation, + desired: self.desired.clone(), + in_flight: self.in_flight.clone(), + pending: self.pending.clone(), + last_successful_geometry: self.last_successful_geometry, + } + } + + pub fn submit( + &mut self, + geometry: TerminalGeometry, + reason: ResizeReason, + ) -> Result { + if !reason.is_forced() && self.ordinary_is_redundant(geometry) { + return Ok(CoordinatorTransition::empty()); + } + + let (intent, next_sequence, next_epoch) = + self.allocate_intent(geometry, reason, self.generation)?; + self.request_sequence = next_sequence; + self.reclaim_epoch = next_epoch; + self.desired = Some(intent.clone()); + + if self.in_flight.is_some() { + self.pending = Some(intent); + Ok(CoordinatorTransition::empty()) + } else { + self.in_flight = Some(intent.clone()); + Ok(CoordinatorTransition::dispatch(intent)) + } + } + + pub fn complete( + &mut self, + completed: &ResizeIntent, + outcome: DeliveryOutcome, + ) -> CoordinatorTransition { + if self.in_flight.as_ref() != Some(completed) { + return CoordinatorTransition::empty(); + } + + let completed = match self.in_flight.take() { + Some(intent) => intent, + None => return CoordinatorTransition::empty(), + }; + if outcome == DeliveryOutcome::Succeeded { + self.last_successful_geometry = Some(completed.geometry); + } + + let pending = self.pending.take(); + match pending { + Some(next) + if outcome == DeliveryOutcome::Succeeded + && !next.reason.is_forced() + && next.geometry == completed.geometry => + { + self.desired = Some(completed); + CoordinatorTransition::empty() + } + Some(next) => { + self.desired = Some(next.clone()); + self.in_flight = Some(next.clone()); + CoordinatorTransition::dispatch(next) + } + None => { + self.desired = Some(completed); + CoordinatorTransition::empty() + } + } + } + + pub fn reconnect( + &mut self, + geometry: TerminalGeometry, + ) -> Result { + let next_generation = self.next_generation()?; + let (intent, next_sequence, next_epoch) = + self.allocate_intent(geometry, ResizeReason::ReconnectReclaim, next_generation)?; + let abort = self.in_flight.take(); + + self.generation = next_generation; + self.request_sequence = next_sequence; + self.reclaim_epoch = next_epoch; + self.desired = Some(intent.clone()); + self.in_flight = Some(intent.clone()); + self.pending = None; + self.last_successful_geometry = None; + + Ok(CoordinatorTransition::abort_and_dispatch(abort, intent)) + } + + pub fn invalidate_generation(&mut self) -> Result { + let next_generation = self.next_generation()?; + let abort = self.in_flight.take(); + self.generation = next_generation; + self.desired = None; + self.pending = None; + self.last_successful_geometry = None; + Ok(CoordinatorTransition { + abort, + dispatch: None, + }) + } + + pub fn replace_terminal(&mut self) -> Result { + self.invalidate_generation() + } + + fn ordinary_is_redundant(&self, geometry: TerminalGeometry) -> bool { + if let Some(pending) = &self.pending { + pending.geometry == geometry + } else if let Some(in_flight) = &self.in_flight { + in_flight.geometry == geometry + } else { + self.last_successful_geometry == Some(geometry) + } + } + + fn next_generation(&self) -> Result { + self.generation + .0 + .checked_add(1) + .map(ResizeGeneration) + .ok_or(CoordinatorError::GenerationExhausted) + } + + fn allocate_intent( + &self, + geometry: TerminalGeometry, + reason: ResizeReason, + generation: ResizeGeneration, + ) -> Result<(ResizeIntent, RequestSequence, ReclaimEpoch), CoordinatorError> { + let next_sequence = self + .request_sequence + .0 + .checked_add(1) + .map(RequestSequence) + .ok_or(CoordinatorError::RequestSequenceExhausted)?; + let next_epoch = if reason.is_forced() { + self.reclaim_epoch + .0 + .checked_add(1) + .map(ReclaimEpoch) + .ok_or(CoordinatorError::ReclaimEpochExhausted)? + } else { + self.reclaim_epoch + }; + Ok(( + ResizeIntent { + geometry, + reason, + reclaim_epoch: next_epoch, + generation, + request_id: next_sequence, + }, + next_sequence, + next_epoch, + )) + } +} + +impl Default for TerminalResizeCoordinator { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::{ + CoordinatorTransition, DeliveryOutcome, ResizeIntent, ResizeReason, TerminalGeometry, + TerminalResizeCoordinator, + }; + + #[derive(Default)] + struct LocalResizeDedupFixture { + last_emitted: Option, + emitted: Vec, + } + + impl LocalResizeDedupFixture { + fn observe(&mut self, geometry: TerminalGeometry) { + if self.last_emitted == Some(geometry) { + return; + } + self.last_emitted = Some(geometry); + self.emitted.push(geometry); + } + } + + fn geometry(columns: u16, rows: u16) -> TerminalGeometry { + TerminalGeometry::new(columns, rows).expect("test geometry must be valid") + } + + fn submit( + coordinator: &mut TerminalResizeCoordinator, + geometry: TerminalGeometry, + reason: ResizeReason, + ) -> CoordinatorTransition { + coordinator + .submit(geometry, reason) + .expect("test sequence must not exhaust coordinator counters") + } + + fn complete( + coordinator: &mut TerminalResizeCoordinator, + intent: &ResizeIntent, + outcome: DeliveryOutcome, + ) -> CoordinatorTransition { + coordinator.complete(intent, outcome) + } + + #[test] + fn baseline_local_resize_dedup_suppresses_equal_geometry() { + let mut fixture = LocalResizeDedupFixture::default(); + let initial = geometry(80, 24); + fixture.observe(initial); + fixture.observe(initial); + assert_eq!(fixture.emitted, vec![initial]); + } + + #[test] + fn latest_wins_dispatches_a_then_c_when_b_is_replaced() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let b = geometry(60, 20); + let c = geometry(50, 16); + let mut dispatched = Vec::new(); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + dispatched.push( + first + .dispatch_request() + .expect("A must dispatch") + .geometry(), + ); + let _ = submit(&mut coordinator, b, ResizeReason::Layout); + let _ = submit(&mut coordinator, c, ResizeReason::PinchStep); + let in_flight_a = coordinator + .snapshot() + .in_flight() + .cloned() + .expect("A remains blocked"); + let after_a = complete(&mut coordinator, &in_flight_a, DeliveryOutcome::Succeeded); + dispatched.push( + after_a + .dispatch_request() + .expect("latest C must dispatch") + .geometry(), + ); + let in_flight_c = coordinator + .snapshot() + .in_flight() + .cloned() + .expect("C is now in flight"); + let _ = complete(&mut coordinator, &in_flight_c, DeliveryOutcome::Succeeded); + assert_eq!(dispatched, vec![a, c]); + assert_eq!(coordinator.snapshot().last_successful_geometry(), Some(c)); + assert!(coordinator.snapshot().pending().is_none()); + } + + #[test] + fn ordinary_a_b_a_collapses_redundant_final_a_after_success() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let b = geometry(60, 20); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + let request_a = first.dispatch_request().cloned().expect("A must dispatch"); + let _ = submit(&mut coordinator, b, ResizeReason::Layout); + let _ = submit(&mut coordinator, a, ResizeReason::Layout); + let after_a = complete(&mut coordinator, &request_a, DeliveryOutcome::Succeeded); + assert!(after_a.dispatch_request().is_none()); + assert_eq!(coordinator.snapshot().last_successful_geometry(), Some(a)); + assert_eq!( + coordinator.snapshot().desired().map(ResizeIntent::geometry), + Some(a) + ); + } + + #[test] + fn equal_size_forced_reclaims_have_distinct_epoch_identity() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let layout = submit(&mut coordinator, a, ResizeReason::Layout); + let request_layout = layout + .dispatch_request() + .cloned() + .expect("layout must dispatch"); + let _ = complete( + &mut coordinator, + &request_layout, + DeliveryOutcome::Succeeded, + ); + let activation = submit(&mut coordinator, a, ResizeReason::ActivationReclaim); + let request_activation = activation + .dispatch_request() + .cloned() + .expect("activation reclaim must bypass geometry dedup"); + let activation_epoch = request_activation.reclaim_epoch(); + let _ = complete( + &mut coordinator, + &request_activation, + DeliveryOutcome::Succeeded, + ); + let interaction = submit(&mut coordinator, a, ResizeReason::InteractionReclaim); + let request_interaction = interaction + .dispatch_request() + .cloned() + .expect("later interaction reclaim must be retained"); + assert!(request_interaction.reclaim_epoch() > activation_epoch); + assert_ne!( + request_interaction.request_id(), + request_activation.request_id() + ); + } + + #[test] + fn later_forced_reclaim_is_not_erased_by_equal_ordinary_intent() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + let request_a = first.dispatch_request().cloned().expect("A must dispatch"); + let forced = submit(&mut coordinator, a, ResizeReason::ActivationReclaim); + assert!(forced.dispatch_request().is_none()); + let _ = submit(&mut coordinator, a, ResizeReason::Layout); + let after_a = complete(&mut coordinator, &request_a, DeliveryOutcome::Succeeded); + let forced_request = after_a + .dispatch_request() + .cloned() + .expect("forced reclaim must survive ordinary duplicate"); + assert_eq!(forced_request.reason(), ResizeReason::ActivationReclaim); + } + + #[test] + fn failure_does_not_mark_success_and_dispatches_latest_pending_once() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let b = geometry(60, 20); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + let request_a = first.dispatch_request().cloned().expect("A must dispatch"); + let _ = submit(&mut coordinator, b, ResizeReason::PinchStep); + let after_failure = complete(&mut coordinator, &request_a, DeliveryOutcome::Failed); + let request_b = after_failure + .dispatch_request() + .cloned() + .expect("B must dispatch after A fails"); + assert_eq!(coordinator.snapshot().last_successful_geometry(), None); + let after_b = complete(&mut coordinator, &request_b, DeliveryOutcome::Succeeded); + assert!(after_b.dispatch_request().is_none()); + assert_eq!(coordinator.snapshot().last_successful_geometry(), Some(b)); + } + + #[test] + fn stale_completion_cannot_clear_new_generation_work() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let c = geometry(50, 16); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + let request_a = first.dispatch_request().cloned().expect("A must dispatch"); + let reconnect = coordinator + .reconnect(c) + .expect("reconnect must create a new generation"); + let request_c = reconnect + .dispatch_request() + .cloned() + .expect("C must dispatch immediately"); + let stale = complete(&mut coordinator, &request_a, DeliveryOutcome::Succeeded); + assert!(stale.dispatch_request().is_none()); + assert_eq!(coordinator.snapshot().last_successful_geometry(), None); + assert_eq!(coordinator.snapshot().in_flight(), Some(&request_c)); + let _ = complete(&mut coordinator, &request_c, DeliveryOutcome::Succeeded); + assert_eq!(coordinator.snapshot().last_successful_geometry(), Some(c)); + } + + #[test] + fn reconnect_aborts_blocked_a_and_dispatches_latest_c_immediately() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let c = geometry(50, 16); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + let request_a = first.dispatch_request().cloned().expect("A must dispatch"); + let reconnect = coordinator + .reconnect(c) + .expect("reconnect must invalidate the blocked generation"); + assert_eq!(reconnect.abort_request(), Some(&request_a)); + assert_eq!( + reconnect.dispatch_request().map(ResizeIntent::geometry), + Some(c) + ); + assert_ne!( + request_a.generation(), + reconnect.dispatch_request().expect("C").generation() + ); + } + + #[test] + fn terminal_replacement_aborts_old_work_and_clears_success() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + let request_a = first.dispatch_request().cloned().expect("A must dispatch"); + let replacement = coordinator + .replace_terminal() + .expect("replacement must invalidate the generation"); + assert_eq!(replacement.abort_request(), Some(&request_a)); + assert!(replacement.dispatch_request().is_none()); + assert!(coordinator.snapshot().last_successful_geometry().is_none()); + let stale = complete(&mut coordinator, &request_a, DeliveryOutcome::Succeeded); + assert!(stale.dispatch_request().is_none()); + let next = submit(&mut coordinator, a, ResizeReason::Layout); + assert!(next.dispatch_request().is_some()); + } + + #[test] + fn geometry_rejects_zero_and_overflow_without_wrapping() { + assert!(TerminalGeometry::new(0, 24).is_err()); + assert!(TerminalGeometry::new(80, 0).is_err()); + assert!(TerminalGeometry::from_usize(usize::from(u16::MAX) + 1, 24).is_err()); + assert!(TerminalGeometry::from_usize(80, usize::from(u16::MAX) + 1).is_err()); + } + + #[test] + fn deterministic_fake_dispatch_trace_covers_blocked_replacement_and_reconnect_abort() { + let mut coordinator = TerminalResizeCoordinator::new(); + let a = geometry(80, 24); + let b = geometry(60, 20); + let c = geometry(50, 16); + let mut calls = Vec::new(); + let first = submit(&mut coordinator, a, ResizeReason::Layout); + calls.push(first.dispatch_request().expect("A").geometry()); + println!( + "fake-rpc dispatch A={a:?} state={:?}", + coordinator.snapshot() + ); + let _ = submit(&mut coordinator, b, ResizeReason::Layout); + let _ = submit(&mut coordinator, c, ResizeReason::PinchStep); + println!( + "fake-rpc blocked A, replaced B with C state={:?}", + coordinator.snapshot() + ); + let request_a = coordinator.snapshot().in_flight().cloned().expect("A"); + let after_a = complete(&mut coordinator, &request_a, DeliveryOutcome::Succeeded); + calls.push(after_a.dispatch_request().expect("C").geometry()); + println!( + "fake-rpc release A, dispatch C={c:?} state={:?}", + coordinator.snapshot() + ); + let request_c = coordinator.snapshot().in_flight().cloned().expect("C"); + let _ = complete(&mut coordinator, &request_c, DeliveryOutcome::Succeeded); + let reconnect = coordinator.reconnect(c).expect("reconnect"); + println!( + "fake-rpc reconnect abort path state={:?}", + coordinator.snapshot() + ); + assert_eq!(calls, vec![a, c]); + assert!(reconnect.dispatch_request().is_some()); + } +} From 841222fbe1871287794f82da1c8274b96cfc8fb2 Mon Sep 17 00:00:00 2001 From: longdd Date: Tue, 15 Sep 2026 03:11:01 +0700 Subject: [PATCH 05/10] feat(terminal): apply pinch zoom to terminal metrics Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/zedra-terminal/src/terminal.rs | 95 +++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 12 deletions(-) diff --git a/crates/zedra-terminal/src/terminal.rs b/crates/zedra-terminal/src/terminal.rs index fd970df3..fef6373e 100644 --- a/crates/zedra-terminal/src/terminal.rs +++ b/crates/zedra-terminal/src/terminal.rs @@ -28,6 +28,8 @@ pub enum TerminalEvent { cols: u16, rows: u16, }, + InteractionStarted, + UserOutboundInput, TitleChanged(Option), OscEvent(OscEvent), OpenHyperlink(TerminalHyperlink), @@ -47,6 +49,14 @@ pub enum TerminalEvent { NativePasteMenuRequested { position: GpuiPoint, }, + /// Haptic feedback for one accepted pinch zoom step. The app maps this to + /// the platform bridge; the terminal crate never touches platform APIs. + PinchStepHaptic, + /// A pinch gesture settled (ended or cancelled) with a changed font size. + /// Emitted once per gesture; the app persists the size and forwards it. + PinchSettled { + font_size: u8, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -419,7 +429,7 @@ impl Terminal { self.send_terminal_event(TerminalEvent::TitleChanged(None)); } AlacTermEvent::PtyWrite(text) => { - self.send_bytes_sync(text.into_bytes()); + self.send_protocol_reply(text.into_bytes()); } AlacTermEvent::TextAreaSizeRequest(format) => { let window_size = alacritty_terminal::event::WindowSize { @@ -428,7 +438,7 @@ impl Terminal { cell_width: (self.size.cell_width / px(1.0)) as u16, cell_height: (self.size.line_height / px(1.0)) as u16, }; - self.send_bytes_sync(format(window_size).into_bytes()); + self.send_protocol_reply(format(window_size).into_bytes()); } AlacTermEvent::ColorRequest(_index, _format) => { // The host answers OSC 10/11/12 color queries inline at the PTY boundary @@ -441,6 +451,15 @@ impl Terminal { } } + fn send_protocol_reply(&self, bytes: Vec) { + let Some(tx) = &self.input_tx else { + return; + }; + if let Err(e) = tx.try_send(bytes) { + error!("failed to send bytes: {:?}", e); + } + } + fn send_terminal_event(&self, event: TerminalEvent) { if let Err(e) = self.event_tx.send(event) { error!("failed to send terminal event: {:?}", e); @@ -691,15 +710,19 @@ impl Terminal { Some(Point::new(Line(line), Column(column))) } - pub fn send_bytes_sync(&self, bytes: Vec) { - if let Some(tx) = &self.input_tx { - if let Err(e) = tx.try_send(bytes) { - error!("failed to send bytes: {:?}", e); - } + pub fn send_bytes_sync(&self, bytes: Vec) -> bool { + let Some(tx) = &self.input_tx else { + return false; + }; + if let Err(e) = tx.try_send(bytes) { + error!("failed to send bytes: {:?}", e); + return false; } + self.send_terminal_event(TerminalEvent::UserOutboundInput); + true } - pub fn paste_text(&self, text: &str) { + pub fn paste_text(&self, text: &str) -> bool { let bracketed = self.mode.contains(TermMode::BRACKETED_PASTE); let bytes = if bracketed { let text = text.replace('\x1b', ""); @@ -708,11 +731,15 @@ impl Terminal { text.replace("\r\n", "\r").replace('\n', "\r").into_bytes() }; - if let Some(tx) = &self.input_tx { - if let Err(error) = tx.try_send(bytes) { - error!("failed to send pasted text: {:?}", error); - } + let Some(tx) = &self.input_tx else { + return false; + }; + if let Err(error) = tx.try_send(bytes) { + error!("failed to send pasted text: {:?}", error); + return false; } + self.send_terminal_event(TerminalEvent::UserOutboundInput); + true } pub fn selection_range(&self) -> Option> { @@ -4903,4 +4930,48 @@ mod tests { key_char: Some(key.to_string()), } } + + #[test] + fn user_keystroke_emits_outbound_input_signal() { + use tokio::sync::mpsc; + let (input_tx, mut input_rx) = mpsc::channel(4); + let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0)); + terminal.input_tx = Some(input_tx); + let mut events = terminal.subscribe_events(); + terminal.handle_keystroke(&plain_keystroke("a")); + let sent = input_rx.try_recv().expect("keystroke enqueues bytes"); + assert!(!sent.is_empty()); + let event = events.try_recv().expect("keystroke signals outbound input"); + assert!(matches!(event, TerminalEvent::UserOutboundInput)); + } + + #[test] + fn user_paste_emits_outbound_input_signal() { + use tokio::sync::mpsc; + let (input_tx, mut input_rx) = mpsc::channel(4); + let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0)); + terminal.input_tx = Some(input_tx); + let mut events = terminal.subscribe_events(); + assert!(terminal.paste_text("hello")); + let sent = input_rx.try_recv().expect("paste enqueues bytes"); + assert!(!sent.is_empty()); + let event = events.try_recv().expect("paste signals outbound input"); + assert!(matches!(event, TerminalEvent::UserOutboundInput)); + } + + #[test] + fn protocol_reply_does_not_emit_outbound_input_signal() { + use tokio::sync::mpsc; + let (input_tx, mut input_rx) = mpsc::channel(4); + let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0)); + terminal.input_tx = Some(input_tx); + let mut events = terminal.subscribe_events(); + terminal.send_protocol_reply(b"reply".to_vec()); + let sent = input_rx.try_recv().expect("protocol reply enqueues bytes"); + assert!(!sent.is_empty()); + assert!( + events.try_recv().is_err(), + "protocol replies must not trigger post-input reclaim" + ); + } } From e89c59d93d69bd60ef2dc46eca9bafc6f6e1bd57 Mon Sep 17 00:00:00 2001 From: longdd Date: Tue, 15 Sep 2026 03:11:33 +0700 Subject: [PATCH 06/10] fix(app): reclaim mobile terminal size safely Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/zedra/src/app.rs | 28 +- crates/zedra/src/workspace.rs | 25 ++ crates/zedra/src/workspace_terminal.rs | 513 +++++++++++++++++++++++-- 3 files changed, 521 insertions(+), 45 deletions(-) diff --git a/crates/zedra/src/app.rs b/crates/zedra/src/app.rs index d2a52391..ac67ad4c 100644 --- a/crates/zedra/src/app.rs +++ b/crates/zedra/src/app.rs @@ -169,10 +169,11 @@ impl ZedraApp { any(target_os = "ios", target_os = "android") ))] { - let workspaces = workspaces.downgrade(); - let web_client_workspaces = workspaces.clone(); + let workspaces_tunnel = workspaces.downgrade(); + let web_client_workspaces = workspaces_tunnel.clone(); + let resize_state_workspaces = workspaces_tunnel.clone(); cx.register_devtool_action("web-tunnel", move |params, _window, cx| { - let Some(workspaces) = workspaces.upgrade() else { + let Some(workspaces) = workspaces_tunnel.upgrade() else { return serde_json::json!({"ok": false, "error": "workspaces dropped"}); }; let url = params @@ -242,6 +243,27 @@ impl ZedraApp { }); serde_json::json!({"ok": true, "slug": slug}) }); + + // Structured resize/pinch observability for the stepped pinch-zoom plan + // (`devtool.sh call terminal-resize-state`). Reports geometry, font size, + // pinch state, coordinator in-flight/pending/last-successful, and reclaim + // epoch flags. Never includes terminal text or user data. + cx.register_devtool_action("terminal-resize-state", move |params, _window, cx| { + let Some(workspaces) = resize_state_workspaces.upgrade() else { + return serde_json::json!({"ok": false, "error": "workspaces dropped"}); + }; + let Some(workspace) = workspaces.read(cx).active().cloned() else { + return serde_json::json!({"ok": false, "error": "no active workspace"}); + }; + let terminal_id = params + .get("terminal_id") + .and_then(|v| v.as_str()) + .map(str::to_string); + let state = workspace + .read(cx) + .terminal_resize_debug_state(terminal_id.as_deref(), cx); + serde_json::json!({"ok": true, "state": state}) + }); } // --- Home view --- diff --git a/crates/zedra/src/workspace.rs b/crates/zedra/src/workspace.rs index 0ff05356..eafd493d 100644 --- a/crates/zedra/src/workspace.rs +++ b/crates/zedra/src/workspace.rs @@ -2974,6 +2974,7 @@ impl Workspace { prev.update(cx, |t, cx| t.deactivate(cx)); } } + entity.update(cx, |terminal, cx| terminal.activate(cx)); self.content.update(cx, |c, cx| { c.set_terminal_subtitle(id, cx); c.set_main_view(entity.into(), cx); @@ -3217,6 +3218,30 @@ impl Workspace { .cloned() } + #[cfg(all( + debug_assertions, + feature = "devtool", + any(target_os = "ios", target_os = "android") + ))] + pub fn terminal_resize_debug_state( + &self, + terminal_id: Option<&str>, + cx: &App, + ) -> serde_json::Value { + let terminal = terminal_id + .and_then(|id| { + self.terminals + .iter() + .find(|t| t.read(cx).terminal_id() == id) + .cloned() + }) + .or_else(|| self.terminals.first().cloned()); + match terminal { + Some(terminal) => terminal.read(cx).resize_debug_state(cx), + None => serde_json::json!({"error": "no terminal"}), + } + } + fn add_to_chat_targets(&self, cx: &mut Context) -> Vec { self.workspace_state .read(cx) diff --git a/crates/zedra/src/workspace_terminal.rs b/crates/zedra/src/workspace_terminal.rs index 26b32f4c..1b9e0962 100644 --- a/crates/zedra/src/workspace_terminal.rs +++ b/crates/zedra/src/workspace_terminal.rs @@ -1,3 +1,4 @@ +use std::num::NonZeroU16; use std::time::Duration; use gpui::{prelude::FluentBuilder as _, *}; @@ -19,6 +20,10 @@ use crate::platform_bridge::{ }; use crate::settings::{ThemeStateEvent, theme_state as theme_entity}; use crate::telemetry::view_telemetry; +use crate::terminal_resize_coordinator::{ + CoordinatorTransition, DeliveryOutcome, ResizeIntent, ResizeReason, TerminalGeometry, + TerminalResizeCoordinator, +}; use crate::terminal_state::TerminalState; use crate::ui::any_drawer_open; use crate::workspace_state::{WorkspaceState, WorkspaceStateEvent}; @@ -28,6 +33,32 @@ const SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_LINES: usize = 10; const SCROLL_TO_BOTTOM_BUTTON_DISMISS_DELAY: Duration = Duration::from_millis(160); const NATIVE_PASTE_MENU_TAP_GAP: f32 = 28.0; +fn workspace_resize_geometry(cols: u16, rows: u16) -> Option { + u32::from(cols) + .try_into() + .ok() + .and_then(NonZeroU16::new) + .zip(u32::from(rows).try_into().ok().and_then(NonZeroU16::new)) + .and_then(|(columns, rows)| TerminalGeometry::new(columns.get(), rows.get()).ok()) +} + +#[cfg(test)] +fn coordinator_geometry_for_test(cols: u16, rows: u16) -> TerminalGeometry { + TerminalGeometry::new(cols, rows).expect("test geometry must be nonzero") +} + +#[cfg(test)] +fn submit_coordinator_for_test( + coordinator: &mut TerminalResizeCoordinator, + cols: u16, + rows: u16, + reason: ResizeReason, +) -> CoordinatorTransition { + coordinator + .submit(coordinator_geometry_for_test(cols, rows), reason) + .expect("test should not exhaust coordinator counters") +} + fn native_paste_menu_anchor(position: Point) -> Point { // Keep the edit menu visibly separated from the long-press finger. point(position.x, position.y - px(NATIVE_PASTE_MENU_TAP_GAP)) @@ -48,6 +79,8 @@ pub struct WorkspaceTerminal { session_handle: SessionHandle, terminal_view: Entity, preview: Entity, + resize_coordinator: TerminalResizeCoordinator, + resize_task: Option>, /// Tracks whether the active terminal is in alt-screen mode (vim, opencode, etc.). /// Updated via AltScreenChanged event — never read via terminal_view.read(cx) in render /// to avoid creating a GPUI dependency that causes re-render cascades. @@ -64,9 +97,59 @@ pub struct WorkspaceTerminal { /// Non-input focus target. Moving focus here drops the terminal keyboard without /// clearing focus entirely (which the terminal input would immediately re-grab). container_focus: FocusHandle, + reclaim_epoch: ReclaimEpochState, _subscriptions: Vec, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct ReclaimEpochState { + activation_sent: bool, + interaction_sent: bool, + post_input_sent: bool, +} + +impl ReclaimEpochState { + fn reset(&mut self) { + *self = Self::default(); + } + + fn claim_activation(&mut self) -> bool { + if self.activation_sent { + return false; + } + self.activation_sent = true; + true + } + + fn claim_interaction(&mut self) -> bool { + if self.interaction_sent { + return false; + } + self.interaction_sent = true; + true + } + + fn claim_post_input(&mut self) -> bool { + if self.post_input_sent { + return false; + } + self.post_input_sent = true; + true + } + + fn activation_sent(&self) -> bool { + self.activation_sent + } + + fn interaction_sent(&self) -> bool { + self.interaction_sent + } + + fn post_input_sent(&self) -> bool { + self.post_input_sent + } +} + impl WorkspaceTerminal { fn sync_terminal_theme(&mut self, cx: &mut Context) { let terminal_theme = crate::theme::bundle(cx).terminal; @@ -159,6 +242,10 @@ impl WorkspaceTerminal { self.scroll_to_bottom_button_hide_pending = false; self.scroll_to_bottom_button_hide_generation = self.scroll_to_bottom_button_hide_generation.wrapping_add(1); + self.reclaim_epoch.reset(); + self.terminal_view.update(cx, |terminal_view, _| { + terminal_view.reset_reclaim_epoch(); + }); hide_native_floating_button(self.scroll_to_bottom_button_id); platform_bridge::hide_native_dictation_preview(self.dictation_preview_id); self.set_scroll_to_bottom_button_visible(false, false, cx); @@ -211,22 +298,26 @@ impl WorkspaceTerminal { let attach_sub = cx.subscribe(&workspace_state, |this, _ws, event, cx| match event { WorkspaceStateEvent::SyncComplete => { info!("received SyncComplete event, attempt to attach input/output channel"); - Self::attach_channel_to_terminal_view( + if Self::attach_channel_to_terminal_view( this.session_handle.clone(), this.terminal_id.clone(), this.terminal_view.clone(), cx, - ); + ) { + this.on_channel_attached(cx); + } } WorkspaceStateEvent::TerminalCreated { id } => { if this.terminal_id == *id { info!("received TerminalCreated event, attempt to attach input/output channel"); - Self::attach_channel_to_terminal_view( + if Self::attach_channel_to_terminal_view( this.session_handle.clone(), this.terminal_id.clone(), this.terminal_view.clone(), cx, - ); + ) { + this.on_channel_attached(cx); + } } } WorkspaceStateEvent::TerminalOpened { id } => { @@ -240,8 +331,17 @@ impl WorkspaceTerminal { }); let initial_viewport = Self::viewport_without_keyboard(initial_viewport); - let terminal_view = - cx.new(|cx| TerminalView::new(terminal_id.clone(), window, initial_viewport, cx)); + let initial_font_size = + zedra_terminal::TerminalFontSize::new(crate::settings::terminal_font_size()); + let terminal_view = cx.new(|cx| { + TerminalView::new_with_font_size( + terminal_id.clone(), + window, + initial_viewport, + initial_font_size, + cx, + ) + }); let workdir = workspace_state.read(cx).workdir.clone(); terminal_view.update(cx, |terminal_view, _cx| { terminal_view.set_workdir(Some(workdir.clone())); @@ -252,13 +352,19 @@ impl WorkspaceTerminal { cx.subscribe(&terminal_view, |this, _terminal, event, cx| match event { TerminalEvent::PreeditChanged => {} TerminalEvent::RequestResize { cols, rows } => { - Self::resize_remote_terminal( - this.session_handle.clone(), - this.terminal_id.clone(), - *cols, - *rows, - cx, - ); + this.submit_resize_request(*cols, *rows, ResizeReason::Layout, cx); + } + TerminalEvent::PinchStepHaptic => { + platform_bridge::trigger_haptic(HapticFeedback::SelectionChanged); + } + TerminalEvent::PinchSettled { font_size } => { + crate::settings::set_terminal_font_size(*font_size); + } + TerminalEvent::InteractionStarted => { + this.on_terminal_interaction(cx); + } + TerminalEvent::UserOutboundInput => { + this.on_user_outbound_input(cx); } TerminalEvent::TitleChanged(title) => { let id = this.terminal_id.clone(); @@ -488,6 +594,9 @@ impl WorkspaceTerminal { session_handle, terminal_view, preview, + resize_coordinator: TerminalResizeCoordinator::new(), + resize_task: None, + reclaim_epoch: ReclaimEpochState::default(), is_alt_screen: false, last_synced_keyboard_inset: px(0.0), scroll_to_bottom_button_id: native_floating_button_id(), @@ -507,21 +616,145 @@ impl WorkspaceTerminal { self.terminal_view.read(cx).input_sender(cx) } + #[cfg(all( + debug_assertions, + feature = "devtool", + any(target_os = "ios", target_os = "android") + ))] + pub fn resize_debug_state(&self, cx: &App) -> serde_json::Value { + let snapshot = self.resize_coordinator.snapshot(); + let (columns, rows) = self.terminal_view.read(cx).remote_size(cx); + let view = self.terminal_view.read(cx); + serde_json::json!({ + "terminal_id": self.terminal_id, + "font_size": view.font_size().as_u8(), + "columns": columns, + "rows": rows, + "pinch_active": view.is_pinch_active(cx), + "coordinator_generation": snapshot.generation().value(), + "desired": snapshot.desired().map(|intent| { + serde_json::json!({ + "columns": intent.geometry().columns(), + "rows": intent.geometry().rows(), + "reason": format!("{:?}", intent.reason()), + "reclaim_epoch": intent.reclaim_epoch().value(), + "request_id": intent.request_id().value(), + }) + }), + "in_flight": snapshot.in_flight().map(|intent| { + serde_json::json!({ + "columns": intent.geometry().columns(), + "rows": intent.geometry().rows(), + "reason": format!("{:?}", intent.reason()), + "request_id": intent.request_id().value(), + }) + }), + "pending": snapshot.pending().map(|intent| { + serde_json::json!({ + "columns": intent.geometry().columns(), + "rows": intent.geometry().rows(), + "reason": format!("{:?}", intent.reason()), + "request_id": intent.request_id().value(), + }) + }), + "last_successful": snapshot.last_successful_geometry().map(|geometry| { + serde_json::json!({ + "columns": geometry.columns(), + "rows": geometry.rows(), + }) + }), + "activation_sent": self.reclaim_epoch.activation_sent(), + "interaction_sent": self.reclaim_epoch.interaction_sent(), + "post_input_sent": self.reclaim_epoch.post_input_sent(), + }) + } + pub fn set_terminal_id(&mut self, terminal_id: String, cx: &mut Context) { self.terminal_id = terminal_id.clone(); self.deactivate(cx); self.terminal_view.update(cx, |terminal_view, _cx| { terminal_view.set_terminal_id(terminal_id); }); + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + + match self.resize_coordinator.replace_terminal() { + Ok(transition) => { + self.apply_resize_transition(transition, cx); + let (cols, rows) = self.terminal_view.read(cx).remote_size(cx); + self.submit_resize_request(cols, rows, ResizeReason::ReconnectReclaim, cx); + } + Err(error) => { + warn!( + terminal_id = self.terminal_id.as_str(), + "resize coordinator exhausted generations on terminal replacement: {}", error + ); + } + } + } + pub fn activate(&mut self, cx: &mut Context) { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + if !self.reclaim_epoch.claim_activation() { + return; + } let (cols, rows) = self.terminal_view.read(cx).remote_size(cx); - Self::resize_remote_terminal( - self.session_handle.clone(), - self.terminal_id.clone(), - cols, - rows, - cx, - ); + self.submit_resize_request(cols, rows, ResizeReason::ActivationReclaim, cx); + } + + pub fn on_terminal_interaction(&mut self, cx: &mut Context) { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + if !self.reclaim_epoch.claim_interaction() { + return; + } + let (cols, rows) = self.terminal_view.read(cx).remote_size(cx); + self.submit_resize_request(cols, rows, ResizeReason::InteractionReclaim, cx); + } + + pub fn on_user_outbound_input(&mut self, cx: &mut Context) { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + if !self.reclaim_epoch.claim_post_input() { + return; + } + let (cols, rows) = self.terminal_view.read(cx).remote_size(cx); + self.submit_resize_request(cols, rows, ResizeReason::PostInputReclaim, cx); + } + + pub fn on_channel_attached(&mut self, cx: &mut Context) { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + let (cols, rows) = self.terminal_view.read(cx).remote_size(cx); + self.resubmit_current_size_as_reconnect(cols, rows, cx); + } + + fn resubmit_current_size_as_reconnect(&mut self, cols: u16, rows: u16, cx: &mut Context) { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + let Some(geometry) = workspace_resize_geometry(cols, rows) else { + warn!( + terminal_id = self.terminal_id.as_str(), + cols, rows, "resize: ignoring invalid geometry after attach/reconnect" + ); + return; + }; + match self.resize_coordinator.reconnect(geometry) { + Ok(transition) => self.apply_resize_transition(transition, cx), + Err(error) => { + warn!( + terminal_id = self.terminal_id.as_str(), + "resize coordinator exhausted generations on reconnect: {}", error + ); + } + } } fn attach_channel_to_terminal_view( @@ -610,37 +843,83 @@ impl WorkspaceTerminal { .detach(); } - fn resize_remote_terminal( - session_handle: SessionHandle, - terminal_id: String, + fn submit_resize_request( + &mut self, cols: u16, rows: u16, + reason: ResizeReason, cx: &mut Context, ) { - if terminal_id == TERMINAL_PENDING_ID { + if self.terminal_id == TERMINAL_PENDING_ID { return; } + let Some(geometry) = workspace_resize_geometry(cols, rows) else { + warn!( + terminal_id = self.terminal_id.as_str(), + cols, rows, "resize: ignoring invalid geometry" + ); + return; + }; + match self.resize_coordinator.submit(geometry, reason) { + Ok(transition) => self.apply_resize_transition(transition, cx), + Err(error) => { + warn!( + terminal_id = self.terminal_id.as_str(), + "resize coordinator exhausted counters: {}", error + ); + } + } + } - cx.spawn(async move |this, cx| { - match session_handle + fn apply_resize_transition( + &mut self, + transition: CoordinatorTransition, + cx: &mut Context, + ) { + // Generation invalidation frees the blocked slot; dropping the stale task + // aborts the obsolete RPC so its completion cannot corrupt the new state. + if transition.abort_request().is_some() { + self.resize_task = None; + } + let Some(intent) = transition.dispatch_request().cloned() else { + return; + }; + self.spawn_resize_dispatch(intent, cx); + } + + fn spawn_resize_dispatch(&mut self, intent: ResizeIntent, cx: &mut Context) { + let session_handle = self.session_handle.clone(); + let terminal_id = self.terminal_id.clone(); + let cols = intent.geometry().columns(); + let rows = intent.geometry().rows(); + self.resize_task = Some(cx.spawn(async move |this, cx| { + let result = session_handle .terminal_resize(&terminal_id, cols, rows) - .await - { - Ok(_) => { - info!(terminal_id, cols, rows, "resized remote terminal"); - if let Err(e) = this.update(cx, |_, cx| cx.notify()) { - warn!("failed to notify from terminal resize task: {}", e); - } - } - Err(error) => { - warn!( - terminal_id, - cols, rows, "failed to resize remote terminal: {}", error - ); - } + .await; + let outcome = if result.is_ok() { + DeliveryOutcome::Succeeded + } else { + DeliveryOutcome::Failed + }; + if let Err(error) = &result { + warn!( + terminal_id, + cols, rows, "failed to resize remote terminal: {}", error + ); + } else { + info!(terminal_id, cols, rows, "resized remote terminal"); } - }) - .detach(); + let dispatch = this + .update(cx, |this, cx| { + let transition = this.resize_coordinator.complete(&intent, outcome); + this.apply_resize_transition(transition, cx); + cx.notify(); + }) + .ok(); + if dispatch.is_none() { + warn!("failed to notify from terminal resize task"); + } + })); } } @@ -769,3 +1048,153 @@ impl Drop for WorkspaceTerminal { platform_bridge::remove_native_dictation_preview(self.dictation_preview_id); } } + +#[cfg(test)] +mod tests { + use crate::terminal_resize_coordinator::{ + DeliveryOutcome, ResizeReason, TerminalResizeCoordinator, + }; + + use super::{ReclaimEpochState, coordinator_geometry_for_test, submit_coordinator_for_test}; + + #[test] + fn workspace_reconnect_resends_equal_size_after_attach() { + let mut coordinator = TerminalResizeCoordinator::new(); + let size = coordinator_geometry_for_test(80, 24); + let first = submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::Layout); + let layout = first + .dispatch_request() + .cloned() + .expect("layout dispatches"); + let _ = coordinator.complete(&layout, DeliveryOutcome::Succeeded); + let reconnect = coordinator.reconnect(size).expect("reconnect dispatches"); + assert_eq!( + reconnect + .dispatch_request() + .expect("equal-size reclaim dispatches") + .geometry(), + size + ); + } + + #[test] + fn workspace_rapid_alternating_sizes_converge_to_final_geometry() { + let mut coordinator = TerminalResizeCoordinator::new(); + let first = submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::Layout); + let in_flight = first + .dispatch_request() + .cloned() + .expect("initial layout dispatches"); + let _ = submit_coordinator_for_test(&mut coordinator, 60, 20, ResizeReason::PinchStep); + let _ = submit_coordinator_for_test(&mut coordinator, 70, 22, ResizeReason::Layout); + let _ = submit_coordinator_for_test(&mut coordinator, 50, 16, ResizeReason::PinchStep); + let after_release = coordinator.complete(&in_flight, DeliveryOutcome::Succeeded); + let final_intent = after_release + .dispatch_request() + .cloned() + .expect("final size dispatches"); + assert_eq!( + final_intent.geometry(), + coordinator_geometry_for_test(50, 16) + ); + let _ = coordinator.complete(&final_intent, DeliveryOutcome::Succeeded); + assert_eq!( + coordinator.snapshot().last_successful_geometry(), + Some(coordinator_geometry_for_test(50, 16)) + ); + assert!(coordinator.snapshot().pending().is_none()); + } + + #[test] + fn workspace_replacement_invalidates_old_resize_before_new_id() { + let mut coordinator = TerminalResizeCoordinator::new(); + let first = submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::Layout); + let in_flight = first + .dispatch_request() + .cloned() + .expect("initial layout dispatches"); + let replacement = coordinator + .replace_terminal() + .expect("replacement invalidates"); + assert_eq!(replacement.abort_request(), Some(&in_flight)); + assert!(replacement.dispatch_request().is_none()); + let stale = coordinator.complete(&in_flight, DeliveryOutcome::Succeeded); + assert!(stale.dispatch_request().is_none()); + let next = submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::Layout); + assert!(next.dispatch_request().is_some()); + } + + #[test] + fn workspace_activation_reclaims_without_typing() { + let mut epoch = ReclaimEpochState::default(); + let mut coordinator = TerminalResizeCoordinator::new(); + assert!(epoch.claim_activation()); + let transition = + submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::ActivationReclaim); + let intent = transition + .dispatch_request() + .cloned() + .expect("activation reclaims immediately"); + assert_eq!(intent.reason(), ResizeReason::ActivationReclaim); + assert!(!epoch.claim_activation()); + println!( + "safe-reclaim activation dispatch geometry={:?} reason={:?}", + intent.geometry(), + intent.reason() + ); + } + + #[test] + fn workspace_first_touch_reclaims_with_interaction_reason() { + let mut epoch = ReclaimEpochState::default(); + let mut coordinator = TerminalResizeCoordinator::new(); + assert!(epoch.claim_interaction()); + let transition = + submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::InteractionReclaim); + let intent = transition + .dispatch_request() + .cloned() + .expect("first touch reclaims"); + assert_eq!(intent.reason(), ResizeReason::InteractionReclaim); + assert!(!epoch.claim_interaction()); + println!( + "safe-reclaim interaction dispatch geometry={:?} reason={:?}", + intent.geometry(), + intent.reason() + ); + } + + #[test] + fn workspace_first_keystroke_reclaims_once_per_epoch() { + let mut epoch = ReclaimEpochState::default(); + let mut coordinator = TerminalResizeCoordinator::new(); + assert!(epoch.claim_post_input()); + let transition = + submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::PostInputReclaim); + let intent = transition + .dispatch_request() + .cloned() + .expect("first keystroke reclaims"); + assert_eq!(intent.reason(), ResizeReason::PostInputReclaim); + assert!(!epoch.claim_post_input()); + epoch.reset(); + assert!(epoch.claim_post_input()); + println!( + "safe-reclaim post-input dispatch geometry={:?} reason={:?}", + intent.geometry(), + intent.reason() + ); + } + + #[test] + fn workspace_deactivation_resets_reclaim_epoch() { + let mut epoch = ReclaimEpochState::default(); + assert!(epoch.claim_activation()); + assert!(epoch.claim_interaction()); + assert!(epoch.claim_post_input()); + epoch.reset(); + assert!(epoch.claim_activation()); + assert!(epoch.claim_interaction()); + assert!(epoch.claim_post_input()); + } +} From d960159b4a7d49615530055cc8b0c3b107034764 Mon Sep 17 00:00:00 2001 From: longdd Date: Tue, 15 Sep 2026 03:11:49 +0700 Subject: [PATCH 07/10] docs: add terminal pinch zoom verification Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- docs/MANUAL_TEST.md | 80 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/docs/MANUAL_TEST.md b/docs/MANUAL_TEST.md index c5ad16fe..82215b5c 100644 --- a/docs/MANUAL_TEST.md +++ b/docs/MANUAL_TEST.md @@ -2402,3 +2402,83 @@ fails and exits. `zedra codex resume` prompts instead. No model call is needed: non-resume arguments forward unchanged. 7. From the app, tap a session open in a desktop terminal: the terminal shows the same prompt. Tap one open nowhere: codex resumes with no prompt. + +## 28. Terminal Pinch Zoom & Safe Resize Reclaim + +Verifies discrete stepped pinch zoom (9-24pt), UIKit touch arbitration, +latest-wins resize coordination, and three-phase safe resize reclaim +(activation, first interaction, first user input) across simulator and physical iOS devices. + +### Setup + +1. Build and run with devtool and debug enabled: + `./scripts/run-ios.sh sim --devtool --debug` (or `device --devtool --debug`). +2. Start log capture: `./scripts/ios-log.sh daemon start`. +3. Bridge devtool: `./scripts/devtool.sh bridge-ios`. +4. Verify bridge: `./scripts/devtool.sh ping`. + +### 1. Stepped Pinch Zoom & Haptic Step Sequence + +1. Open a workspace terminal in Zedra. +2. Inspect initial geometry and font size: + `./scripts/devtool.sh call terminal-resize-state` + Expected: `"font_size": 12`, `"pinch_active": false`, initial `columns`/`rows`. +3. Perform a two-finger pinch outward on the terminal surface. + Expected: + - UIKit `TouchPinchArbitrator` claims the gesture once scale delta exceeds threshold (`Started`), quarantining survivor touches and preventing default scroll. + - For each discrete 1pt integer font size change (`12 -> 13 -> 14`), a crisp selection haptic fires (`platform_bridge::trigger_haptic(SelectionChanged)`). + - The Alacritty grid reflows synchronously at each step without intermediate rendering blur or viewport jitter. + - Log shows `[ratchet-trace] accepted step` and `terminal grid resized columns=... rows=...`. +4. Release the pinch at font size 14. + Expected: + - Exactly one `PinchSettled { font_size: 14 }` event fires on gesture end. + - `terminal-resize-state` shows `"font_size": 14`, `"pinch_active": false`. + - Settings persists the new size to disk (`settings.json` has `"terminal_font_size": 14`). +5. Terminate and restart the app (`./scripts/run-ios.sh sim`). + Expected: newly constructed terminal view opens with font size 14 restored from settings (`TerminalView::new_with_font_size`). + +### 2. Selection Gesture Non-Interference + +1. Long press on output text in the terminal to trigger word selection. +2. Expected: selection highlight appears with native edit menu handles; `selection_active()` is true. +3. While selection is active, place two fingers on the terminal and pinch. + Expected: pinch gesture is ignored (`pinch_claimed` remains false); selection range remains intact without accidental zoom or font changes. +4. Clear selection by tapping an empty cell, then pinch again. + Expected: pinch zoom functions normally. + +### 3. Latest-Wins Resize Coordination (A -> B -> C) + +1. Rapidly alternate terminal orientation or pinch sizes while remote RPCs are in flight. +2. Expected: + - At most one resize RPC in flight and at most one latest pending intent queued. + - Obsolete intermediate requests are replaced in-flight. + - Log markers: + `fake-rpc dispatch A=... state=...` + `fake-rpc blocked A, replaced B with C state=...` + `fake-rpc release A, dispatch C=... state=...` + - Terminal converges cleanly on final geometry C without out-of-order race conditions. + +### 4. Safe Resize Reclaim (Activation, First Touch, First Keystroke) + +1. Open or switch to a terminal in the workspace (`Workspace::switch_terminal`). + Expected: `ActivationReclaim` fires immediately with the latest mobile grid without typing; + log marker `safe-reclaim activation dispatch geometry=... reason=ActivationReclaim`. +2. Tap or touch the terminal surface (scroll or tap). + Expected: first touch triggers `InteractionReclaim` (fired once per activation epoch; does not toggle focus or keyboard); + log marker `safe-reclaim interaction dispatch geometry=... reason=InteractionReclaim`. + Subsequent touches in the same epoch do NOT emit additional reclaim events. +3. Type the first user character on the software or hardware keyboard (or paste / tap key bar accessory). + Expected: first accepted outbound keystroke enqueues bytes and triggers `PostInputReclaim`; + log marker `safe-reclaim post-input dispatch geometry=... reason=PostInputReclaim`. + Subsequent keystrokes in the same epoch do NOT trigger additional reclaims. +4. Deactivate the terminal by switching away to another tab or backgrounding the app. + Expected: `reclaim_epoch.reset()` clears the activation, interaction, and post-input flags. + Returning to the terminal starts a clean new activation epoch. + +### 5. Tmux Caveat: Direct PTY vs `window-size latest` + +- **Direct PTY / Remote Shell**: + Every `terminal_resize` RPC sends `SIGWINCH` directly to the foreground process. Passive touch (`InteractionReclaim`) and activation (`ActivationReclaim`) immediately reclaim mobile dimensions. +- **Tmux Sessions (`window-size latest`)**: + When Zedra attaches to a shared tmux session open on desktop, tmux tracks the client window size. Passive touch reclaims the Zedra remote PTY geometry without sending synthetic input bytes. On the first real user keystroke (`PostInputReclaim`), tmux registers client activity and adjusts its `window-size latest` view to the mobile geometry. + Zero synthetic bytes, mouse reports, or tmux commands are ever injected by Zedra. From 18e91b4c96a7d9bcbdc7af057b0f9fe8750f2bdb Mon Sep 17 00:00:00 2001 From: longdd Date: Tue, 15 Sep 2026 03:38:41 +0700 Subject: [PATCH 08/10] fix(terminal): align metric revision and reclaim compliance Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/zedra-terminal/src/element.rs | 16 +-- crates/zedra-terminal/src/lib.rs | 17 ++++ crates/zedra-terminal/src/view.rs | 130 ++++++++++++++++--------- crates/zedra/src/workspace.rs | 12 +-- crates/zedra/src/workspace_terminal.rs | 4 +- docs/MANUAL_TEST.md | 3 +- 6 files changed, 113 insertions(+), 69 deletions(-) diff --git a/crates/zedra-terminal/src/element.rs b/crates/zedra-terminal/src/element.rs index 1a489f05..31191e3d 100644 --- a/crates/zedra-terminal/src/element.rs +++ b/crates/zedra-terminal/src/element.rs @@ -9,7 +9,6 @@ use alacritty_terminal::vte::ansi::{Color as AlacColor, CursorShape, NamedColor} use gpui::*; use itertools::Itertools; -use crate::MONO_FONT_FAMILY; use crate::input::TerminalInputHandler; use crate::selection::TerminalSelectionDocument; use crate::terminal::*; @@ -596,20 +595,7 @@ impl Element for TerminalElement { // Surface identity for native-selection occlusion checks; Normal // behavior so it never blocks other elements. let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); - // Use JetBrains Mono NL - embedded monospace font (loaded once at app init) - let font = Font { - family: MONO_FONT_FAMILY.into(), - features: FontFeatures::default(), - fallbacks: Some(FontFallbacks::from_fonts(vec![ - "Noto Sans Symbols 2".to_string(), - "Apple Symbols".to_string(), - "Menlo".to_string(), - "Droid Sans Mono".to_string(), - "monospace".to_string(), - ])), - weight: FontWeight::NORMAL, - style: FontStyle::Normal, - }; + let font = crate::terminal_font(); // Use configured line height let line_height = self.size.line_height; diff --git a/crates/zedra-terminal/src/lib.rs b/crates/zedra-terminal/src/lib.rs index 3901fe5a..c8b081ea 100644 --- a/crates/zedra-terminal/src/lib.rs +++ b/crates/zedra-terminal/src/lib.rs @@ -23,5 +23,22 @@ use gpui::*; /// The font bytes and loader live in the `zedra` crate (`fonts` module). pub const MONO_FONT_FAMILY: &str = "JetBrainsMonoNL Nerd Font Mono"; +/// Shared terminal font descriptor used by both grid measurement and paint. +pub fn terminal_font() -> gpui::Font { + gpui::Font { + family: MONO_FONT_FAMILY.into(), + features: gpui::FontFeatures::default(), + fallbacks: Some(gpui::FontFallbacks::from_fonts(vec![ + "Noto Sans Symbols 2".to_string(), + "Apple Symbols".to_string(), + "Menlo".to_string(), + "Droid Sans Mono".to_string(), + "monospace".to_string(), + ])), + weight: gpui::FontWeight::NORMAL, + style: gpui::FontStyle::Normal, + } +} + /// The font size for the embedded terminal font. pub const TERMINAL_FONT_SIZE: Pixels = px(12.0); diff --git a/crates/zedra-terminal/src/view.rs b/crates/zedra-terminal/src/view.rs index a76050dc..b1d8d2ad 100644 --- a/crates/zedra-terminal/src/view.rs +++ b/crates/zedra-terminal/src/view.rs @@ -140,7 +140,13 @@ impl TerminalView { viewport: Size, cx: &mut Context, ) -> Self { - let initial_grid_size = TerminalView::compute_grid_size(window, viewport); + let initial_grid_size = + TerminalView::compute_grid_size(window, viewport).unwrap_or(TerminalGridSize { + columns: 0, + rows: 0, + cell_width: px(FALLBACK_CELL_WIDTH), + line_height: TerminalFontSize::DEFAULT.line_height(), + }); let terminal = cx.new(|_cx| { Terminal::new( @@ -221,15 +227,17 @@ impl TerminalView { let initial_grid_size = TerminalView::compute_grid_size_for_font_size(window, viewport, font_size); let mut this = Self::new(terminal_id, window, viewport, cx); - this.set_font_size(font_size, cx); - this.terminal.update(cx, |terminal, _| { - terminal.resize( - initial_grid_size.columns, - initial_grid_size.rows, - initial_grid_size.cell_width, - initial_grid_size.line_height, - ); - }); + if let Some(initial_grid_size) = initial_grid_size { + this.set_font_size(font_size, cx); + this.terminal.update(cx, |terminal, _| { + terminal.resize( + initial_grid_size.columns, + initial_grid_size.rows, + initial_grid_size.cell_width, + initial_grid_size.line_height, + ); + }); + } this } @@ -284,7 +292,10 @@ impl TerminalView { ))) } - pub fn compute_grid_size(window: &mut Window, viewport: Size) -> TerminalGridSize { + pub fn compute_grid_size( + window: &mut Window, + viewport: Size, + ) -> Option { Self::compute_grid_size_for_font_size(window, viewport, TerminalFontSize::DEFAULT) } @@ -292,20 +303,14 @@ impl TerminalView { window: &mut Window, viewport: Size, font_size: TerminalFontSize, - ) -> TerminalGridSize { + ) -> Option { let line_height = font_size.line_height(); let cell_width = Self::measure_cell_width(window, line_height); Self::compute_grid_size_with_metrics(viewport, cell_width, line_height) } fn measure_cell_width(window: &mut Window, line_height: Pixels) -> Pixels { - let font = Font { - family: crate::MONO_FONT_FAMILY.into(), - features: FontFeatures::default(), - fallbacks: None, - weight: FontWeight::NORMAL, - style: FontStyle::Normal, - }; + let font = crate::terminal_font(); let font_size = line_height * 0.75; let text_system = window.text_system(); let font_id = text_system.resolve_font(&font); @@ -319,18 +324,24 @@ impl TerminalView { viewport: Size, cell_width: Pixels, line_height: Pixels, - ) -> TerminalGridSize { - let width = viewport.width.max(px(0.0)); - let height = viewport.height.max(px(0.0)); - let columns = (width / cell_width).floor() as usize; - let rows = (height / line_height).floor() as usize; + ) -> Option { + if Self::reject_degenerate_cell(cell_width, line_height) { + return None; + } + let width = (viewport.width / px(1.0)) as f32; + let height = (viewport.height / px(1.0)) as f32; + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return None; + } + let columns = (width / (cell_width / px(1.0)) as f32).floor() as usize; + let rows = (height / (line_height / px(1.0)) as f32).floor() as usize; - TerminalGridSize { + Some(TerminalGridSize { columns, rows, cell_width, line_height, - } + }) } pub fn is_channel_attached(&self, cx: &mut Context) -> bool { @@ -393,7 +404,11 @@ impl TerminalView { line_height: Pixels, cx: &mut Context, ) { - let next = Self::compute_grid_size_with_metrics(actual_bounds, cell_width, line_height); + let Some(next) = + Self::compute_grid_size_with_metrics(actual_bounds, cell_width, line_height) + else { + return; + }; self.apply_grid_size(next, cx); } @@ -419,13 +434,14 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { - self.font_size = font_size; let line_height = font_size.line_height(); let cell_width = Self::measure_cell_width(window, line_height); - if Self::reject_degenerate_cell(cell_width, line_height) { + let Some(next) = + Self::compute_grid_size_with_metrics(self.viewport, cell_width, line_height) + else { return; - } - let next = Self::compute_grid_size_with_metrics(self.viewport, cell_width, line_height); + }; + self.font_size = font_size; self.apply_grid_size(next, cx); } @@ -437,13 +453,16 @@ impl TerminalView { ) { match event.phase { TouchPhase::Started => { - self.signal_interaction(cx); + self.handle_terminal_pointer_down(cx); if self.terminal.read(cx).selection_active() { self.pinch_claimed = false; return; } window.prevent_default(); self.pinch_claimed = true; + self.scroll_offset_px = 0.0; + self.remote_scroll_offset_px = 0.0; + self.keyboard_top_reveal_px = 0.0; self.pinch_start_font_size = self.font_size; self.zoom_ratchet.set_font_size(self.font_size); self.zoom_ratchet.begin(); @@ -463,14 +482,7 @@ impl TerminalView { } } TouchPhase::Ended => { - if !self.pinch_claimed { - return; - } - self.pinch_claimed = false; - let settled = self.zoom_ratchet.font_size(); - let changed = settled != self.pinch_start_font_size; - self.zoom_ratchet.end(); - if changed { + if let Some(settled) = self.take_pending_pinch_settle() { cx.emit(TerminalEvent::PinchSettled { font_size: settled.as_u8(), }); @@ -657,13 +669,17 @@ impl TerminalView { self.workdir = workdir; } + fn handle_terminal_pointer_down(&mut self, cx: &mut Context) { + self.signal_interaction(cx); + } + fn handle_terminal_press( &mut self, event: &PressEvent, window: &mut Window, cx: &mut Context, ) { - self.signal_interaction(cx); + self.handle_terminal_pointer_down(cx); let position = event.position(); let hyperlink = self.terminal.read(cx).hyperlink_at( @@ -708,7 +724,7 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { - self.signal_interaction(cx); + self.handle_terminal_pointer_down(cx); let position = event.down.position; window.prevent_default(); @@ -754,11 +770,13 @@ impl TerminalView { } pub fn paste_text_from_native_menu(&mut self, text: &str, cx: &mut Context) { - self.terminal.update(cx, |terminal, _| { + let enqueued = self.terminal.update(cx, |terminal, _| { terminal.clear_selection_range(); - terminal.paste_text(text); + terminal.paste_text(text) }); - self.signal_user_outbound_input(cx); + if enqueued { + self.signal_user_outbound_input(cx); + } cx.notify(); } @@ -782,6 +800,25 @@ impl TerminalView { self.interaction_signaled = false; self.outbound_input_signaled = false; } + + fn take_pending_pinch_settle(&mut self) -> Option { + if !self.pinch_claimed { + return None; + } + self.pinch_claimed = false; + let settled = self.zoom_ratchet.font_size(); + self.zoom_ratchet.end(); + (settled != self.pinch_start_font_size).then_some(settled) + } + + pub fn deactivate(&mut self, cx: &mut Context) { + if let Some(settled) = self.take_pending_pinch_settle() { + cx.emit(TerminalEvent::PinchSettled { + font_size: settled.as_u8(), + }); + } + self.reset_reclaim_epoch(); + } } impl EventEmitter for TerminalView {} @@ -814,6 +851,9 @@ impl Render for TerminalView { .on_pinch(cx.listener(|this, event: &PinchEvent, window, cx| { this.handle_terminal_pinch(event, window, cx); })) + .on_pointer_down(cx.listener(|this, _event: &PointerDownEvent, _window, cx| { + this.handle_terminal_pointer_down(cx); + })) .on_press(cx.listener(|this, event: &PressEvent, window, cx| { this.handle_terminal_press(event, window, cx); })) diff --git a/crates/zedra/src/workspace.rs b/crates/zedra/src/workspace.rs index eafd493d..0946ed2a 100644 --- a/crates/zedra/src/workspace.rs +++ b/crates/zedra/src/workspace.rs @@ -2502,9 +2502,9 @@ impl Workspace { let session_handle = self.session.handle().clone(); let initial_viewport = self.mainview_viewport(window, cx); - let initial_grid_size = TerminalView::compute_grid_size(window, initial_viewport); - let cols = initial_grid_size.columns; - let rows = initial_grid_size.rows; + let (cols, rows) = TerminalView::compute_grid_size(window, initial_viewport) + .map(|size| (size.columns, size.rows)) + .unwrap_or((0, 0)); let workspace_terminal = self.create_terminal_entity(TERMINAL_PENDING_ID.to_string(), window, cx); @@ -2679,9 +2679,9 @@ impl Workspace { ) { let session_handle = self.session.handle().clone(); let initial_viewport = self.mainview_viewport(window, cx); - let initial_grid_size = TerminalView::compute_grid_size(window, initial_viewport); - let cols = initial_grid_size.columns; - let rows = initial_grid_size.rows; + let (cols, rows) = TerminalView::compute_grid_size(window, initial_viewport) + .map(|size| (size.columns, size.rows)) + .unwrap_or((0, 0)); let workspace_terminal = self.create_terminal_entity(TERMINAL_PENDING_ID.to_string(), window, cx); let pending_entity_id = workspace_terminal.entity_id(); diff --git a/crates/zedra/src/workspace_terminal.rs b/crates/zedra/src/workspace_terminal.rs index 1b9e0962..4d386a1f 100644 --- a/crates/zedra/src/workspace_terminal.rs +++ b/crates/zedra/src/workspace_terminal.rs @@ -243,8 +243,8 @@ impl WorkspaceTerminal { self.scroll_to_bottom_button_hide_generation = self.scroll_to_bottom_button_hide_generation.wrapping_add(1); self.reclaim_epoch.reset(); - self.terminal_view.update(cx, |terminal_view, _| { - terminal_view.reset_reclaim_epoch(); + self.terminal_view.update(cx, |terminal_view, cx| { + terminal_view.deactivate(cx); }); hide_native_floating_button(self.scroll_to_bottom_button_id); platform_bridge::hide_native_dictation_preview(self.dictation_preview_id); diff --git a/docs/MANUAL_TEST.md b/docs/MANUAL_TEST.md index 82215b5c..846916b6 100644 --- a/docs/MANUAL_TEST.md +++ b/docs/MANUAL_TEST.md @@ -2480,5 +2480,6 @@ latest-wins resize coordination, and three-phase safe resize reclaim - **Direct PTY / Remote Shell**: Every `terminal_resize` RPC sends `SIGWINCH` directly to the foreground process. Passive touch (`InteractionReclaim`) and activation (`ActivationReclaim`) immediately reclaim mobile dimensions. - **Tmux Sessions (`window-size latest`)**: - When Zedra attaches to a shared tmux session open on desktop, tmux tracks the client window size. Passive touch reclaims the Zedra remote PTY geometry without sending synthetic input bytes. On the first real user keystroke (`PostInputReclaim`), tmux registers client activity and adjusts its `window-size latest` view to the mobile geometry. + When Zedra attaches to a shared tmux session open on desktop, tmux tracks the active client window size. Safe resize reclaim and post-input convergence are a **best-effort transport separation across independent input and resize RPC channels**—not a strict synchronous host processing barrier. + Passive touch reclaims the Zedra remote PTY geometry without sending synthetic noise. On the first real user keystroke (`PostInputReclaim`), tmux registers client activity and adjusts its `window-size latest` view to the mobile geometry as a best-effort sequence. Zero synthetic bytes, mouse reports, or tmux commands are ever injected by Zedra. From 81eb2d8934636ff1f8b4ddea11bf619c10220d3b Mon Sep 17 00:00:00 2001 From: longdd Date: Tue, 15 Sep 2026 03:56:28 +0700 Subject: [PATCH 09/10] fix(terminal): add metric revision guard, coordinator authority, and key-bar activity Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/zedra-terminal/src/element.rs | 8 +++- crates/zedra-terminal/src/terminal.rs | 4 ++ crates/zedra-terminal/src/view.rs | 56 ++++++++++++++++++++++---- crates/zedra/src/active_terminal.rs | 45 +++++++++++++++++---- crates/zedra/src/workspace_terminal.rs | 50 +++++++++++++++++++++++ 5 files changed, 147 insertions(+), 16 deletions(-) diff --git a/crates/zedra-terminal/src/element.rs b/crates/zedra-terminal/src/element.rs index 31191e3d..d3febe3d 100644 --- a/crates/zedra-terminal/src/element.rs +++ b/crates/zedra-terminal/src/element.rs @@ -188,6 +188,7 @@ pub struct TerminalElement { focus_handle: FocusHandle, focused: bool, selection_active: bool, + metric_revision: u64, } impl TerminalElement { @@ -203,6 +204,7 @@ impl TerminalElement { focus_handle: FocusHandle, focused: bool, selection_active: bool, + metric_revision: u64, ) -> Self { Self { content, @@ -216,6 +218,7 @@ impl TerminalElement { focus_handle, focused, selection_active, + metric_revision, } } @@ -756,9 +759,12 @@ impl Element for TerminalElement { if needs_reconcile { let view = self.view.clone(); let actual_bounds = bounds.size; + let revision = self.metric_revision; window.defer(cx, move |_window, cx| { let _ = view.update(cx, |view, cx| { - view.reconcile_bounds_fallback(actual_bounds, cell_width, line_height, cx); + if view.metric_revision() == revision { + view.reconcile_bounds_fallback(actual_bounds, cell_width, line_height, cx); + } }); }); } diff --git a/crates/zedra-terminal/src/terminal.rs b/crates/zedra-terminal/src/terminal.rs index fef6373e..f31a092c 100644 --- a/crates/zedra-terminal/src/terminal.rs +++ b/crates/zedra-terminal/src/terminal.rs @@ -466,6 +466,10 @@ impl Terminal { } } + pub fn outbound_input_sender(&self) -> tokio::sync::broadcast::Sender { + self.event_tx.clone() + } + /// Feed bytes from PTY output buffer into the OSC scanner /// and emit events to the event channel. pub fn feed_osc_bytes(&mut self, bytes: &[u8]) { diff --git a/crates/zedra-terminal/src/view.rs b/crates/zedra-terminal/src/view.rs index b1d8d2ad..a2339805 100644 --- a/crates/zedra-terminal/src/view.rs +++ b/crates/zedra-terminal/src/view.rs @@ -123,6 +123,7 @@ pub struct TerminalView { pub retains_focus_without_keyboard: bool, terminal_theme: TerminalTheme, font_size: TerminalFontSize, + metric_revision: u64, zoom_ratchet: TerminalZoomRatchet, pinch_claimed: bool, pinch_start_font_size: TerminalFontSize, @@ -206,6 +207,7 @@ impl TerminalView { retains_focus_without_keyboard: false, terminal_theme: TerminalTheme::dark(), font_size: TerminalFontSize::DEFAULT, + metric_revision: 0, zoom_ratchet: TerminalZoomRatchet::new(TerminalFontSize::DEFAULT), pinch_claimed: false, pinch_start_font_size: TerminalFontSize::DEFAULT, @@ -243,6 +245,7 @@ impl TerminalView { pub fn set_font_size(&mut self, font_size: TerminalFontSize, cx: &mut Context) { self.font_size = TerminalFontSize::new(font_size.as_u8()); + self.metric_revision = self.metric_revision.wrapping_add(1); self.zoom_ratchet.set_font_size(self.font_size); cx.notify(); } @@ -251,6 +254,20 @@ impl TerminalView { self.font_size } + pub fn metric_revision(&self) -> u64 { + self.metric_revision + } + + pub fn outbound_input_activity_callback( + &self, + cx: &App, + ) -> Option> { + let tx = self.terminal.read(cx).outbound_input_sender(); + Some(std::sync::Arc::new(move || { + let _ = tx.send(TerminalEvent::UserOutboundInput); + })) + } + pub fn set_terminal_id(&mut self, terminal_id: String) { self.terminal_id = terminal_id; } @@ -442,6 +459,7 @@ impl TerminalView { return; }; self.font_size = font_size; + self.metric_revision = self.metric_revision.wrapping_add(1); self.apply_grid_size(next, cx); } @@ -512,19 +530,11 @@ impl TerminalView { cx.notify(); } - if !changed && self.last_remote_size == Some(next.remote_size()) { - return; - } - self.resize_remote_pty(next, cx); } fn resize_remote_pty(&mut self, next: TerminalGridSize, cx: &mut Context) { let remote_size = next.remote_size(); - if self.last_remote_size == Some(remote_size) { - return; - } - self.last_remote_size = Some(remote_size); cx.emit(TerminalEvent::RequestResize { cols: remote_size.0, @@ -1014,6 +1024,7 @@ impl Render for TerminalView { self.focus_handle.clone(), self.focus_handle.is_focused(window), selection_active, + self.metric_revision, )) } } @@ -2208,4 +2219,33 @@ mod tests { assert_eq!(settled, vec![13]); cx.quit(); } + + #[test] + fn metric_revision_increments_on_font_step_and_set_font_size() { + let mut cx = TestAppContext::single(); + let window = open_terminal_window(&mut cx); + cx.run_until_parked(); + + let initial_rev = window + .update(&mut cx, |view, _, _| view.metric_revision()) + .unwrap(); + assert_eq!(initial_rev, 0); + + window + .update(&mut cx, |view, _, cx| { + view.set_font_size(TerminalFontSize::new(14), cx); + }) + .unwrap(); + let after_set = window + .update(&mut cx, |view, _, _| view.metric_revision()) + .unwrap(); + assert_eq!(after_set, 1); + + pinch_enter_sequence(window, &mut cx, &[0.04, 0.04, 0.03]); + let after_pinch = window + .update(&mut cx, |view, _, _| view.metric_revision()) + .unwrap(); + assert!(after_pinch > after_set); + cx.quit(); + } } diff --git a/crates/zedra/src/active_terminal.rs b/crates/zedra/src/active_terminal.rs index f590da1e..988104f8 100644 --- a/crates/zedra/src/active_terminal.rs +++ b/crates/zedra/src/active_terminal.rs @@ -8,15 +8,18 @@ /// /// Nothing in `zedra-session` is involved — the routing is entirely /// within the `zedra` crate. -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use tokio::sync::mpsc; use tracing::warn; +pub type ActivityCallback = Arc; + #[derive(Clone)] struct ActiveInput { terminal_id: String, sender: mpsc::Sender>, + on_activity: Option, } static ACTIVE_INPUT: OnceLock>> = OnceLock::new(); @@ -25,18 +28,32 @@ fn slot() -> &'static Mutex> { ACTIVE_INPUT.get_or_init(|| Mutex::new(None)) } -/// Register the input channel for the currently-active terminal. -/// -/// Called by `WorkspaceView` whenever `active_terminal_id` changes. pub fn set_active_input(terminal_id: String, sender: mpsc::Sender>) { + set_active_input_with_activity(terminal_id, sender, None); +} + +pub fn set_active_input_with_activity( + terminal_id: String, + sender: mpsc::Sender>, + on_activity: Option, +) { if let Ok(mut slot) = slot().lock() { *slot = Some(ActiveInput { terminal_id, sender, + on_activity, }); } } +pub fn register_active_input_with_activity( + terminal_id: String, + sender: mpsc::Sender>, + on_activity: Option, +) { + set_active_input_with_activity(terminal_id, sender, on_activity); +} + /// Clear the active input channel, but only if `terminal_id` still owns the slot. /// /// A stale close (e.g. an old terminal tearing down after a newer one became @@ -63,7 +80,12 @@ pub fn send_to_active(data: Vec) -> bool { }; match active.sender.try_send(data) { - Ok(()) => true, + Ok(()) => { + if let Some(cb) = &active.on_activity { + cb(); + } + true + } Err(error) => { warn!( terminal_id = active.terminal_id, @@ -77,6 +99,7 @@ pub fn send_to_active(data: Vec) -> bool { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::mpsc::error::TryRecvError; // Single test: the module holds one process-global slot, so parallel tests @@ -87,16 +110,24 @@ mod tests { let (first_tx, mut first_rx) = mpsc::channel(1); let (second_tx, mut second_rx) = mpsc::channel(1); + let activity_fired = Arc::new(AtomicBool::new(false)); + let activity_fired_clone = activity_fired.clone(); set_active_input("first".to_string(), first_tx); - set_active_input("second".to_string(), second_tx); + set_active_input_with_activity( + "second".to_string(), + second_tx, + Some(Arc::new(move || { + activity_fired_clone.store(true, Ordering::SeqCst); + })), + ); - // send_to_active reads the latest registered channel. assert!(send_to_active(b"\t".to_vec())); assert!(matches!( first_rx.try_recv(), Err(TryRecvError::Empty | TryRecvError::Disconnected) )); assert_eq!(second_rx.try_recv(), Ok(b"\t".to_vec())); + assert!(activity_fired.load(Ordering::SeqCst)); // A stale close for a different terminal must not drop the active slot. clear_active_input("first"); diff --git a/crates/zedra/src/workspace_terminal.rs b/crates/zedra/src/workspace_terminal.rs index 4d386a1f..17b8b2be 100644 --- a/crates/zedra/src/workspace_terminal.rs +++ b/crates/zedra/src/workspace_terminal.rs @@ -239,6 +239,7 @@ impl WorkspaceTerminal { } pub fn deactivate(&mut self, cx: &mut Context) { + crate::active_terminal::clear_active_input(&self.terminal_id); self.scroll_to_bottom_button_hide_pending = false; self.scroll_to_bottom_button_hide_generation = self.scroll_to_bottom_button_hide_generation.wrapping_add(1); @@ -698,6 +699,7 @@ impl WorkspaceTerminal { if self.terminal_id == TERMINAL_PENDING_ID { return; } + self.register_active_input(cx); if !self.reclaim_epoch.claim_activation() { return; } @@ -731,10 +733,29 @@ impl WorkspaceTerminal { if self.terminal_id == TERMINAL_PENDING_ID { return; } + self.register_active_input(cx); let (cols, rows) = self.terminal_view.read(cx).remote_size(cx); self.resubmit_current_size_as_reconnect(cols, rows, cx); } + fn register_active_input(&self, cx: &App) { + if self.terminal_id == TERMINAL_PENDING_ID { + return; + } + let Some(sender) = self.terminal_view.read(cx).input_sender(cx) else { + return; + }; + let on_activity = self + .terminal_view + .read(cx) + .outbound_input_activity_callback(cx); + crate::active_terminal::set_active_input_with_activity( + self.terminal_id.clone(), + sender, + on_activity, + ); + } + fn resubmit_current_size_as_reconnect(&mut self, cols: u16, rows: u16, cx: &mut Context) { if self.terminal_id == TERMINAL_PENDING_ID { return; @@ -1044,6 +1065,7 @@ impl Render for WorkspaceTerminal { impl Drop for WorkspaceTerminal { fn drop(&mut self) { + crate::active_terminal::clear_active_input(&self.terminal_id); hide_native_floating_button(self.scroll_to_bottom_button_id); platform_bridge::remove_native_dictation_preview(self.dictation_preview_id); } @@ -1197,4 +1219,32 @@ mod tests { assert!(epoch.claim_interaction()); assert!(epoch.claim_post_input()); } + + #[test] + fn workspace_key_bar_active_input_triggers_post_input_reclaim() { + let mut epoch = ReclaimEpochState::default(); + let mut coordinator = TerminalResizeCoordinator::new(); + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let called_clone = called.clone(); + crate::active_terminal::set_active_input_with_activity( + "keybar-test".to_string(), + tx, + Some(std::sync::Arc::new(move || { + called_clone.store(true, std::sync::atomic::Ordering::SeqCst); + })), + ); + assert!(crate::active_terminal::send_to_active(b"\r".to_vec())); + assert_eq!(rx.try_recv(), Ok(b"\r".to_vec())); + assert!(called.load(std::sync::atomic::Ordering::SeqCst)); + + assert!(epoch.claim_post_input()); + let transition = + submit_coordinator_for_test(&mut coordinator, 80, 24, ResizeReason::PostInputReclaim); + assert_eq!( + transition.dispatch_request().map(|i| i.reason()), + Some(ResizeReason::PostInputReclaim) + ); + crate::active_terminal::clear_active_input("keybar-test"); + } } From 9119367b31d4fe26ebf2974f52ca1e0e9e7473ca Mon Sep 17 00:00:00 2001 From: longdd Date: Tue, 15 Sep 2026 04:11:25 +0700 Subject: [PATCH 10/10] fix(terminal): remove last_remote_size, wire async send activity, and enforce teardown settle Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/zedra-terminal/src/terminal.rs | 50 ++++++++++++++++++++++---- crates/zedra-terminal/src/view.rs | 30 +++------------- crates/zedra/src/workspace_terminal.rs | 4 ++- 3 files changed, 51 insertions(+), 33 deletions(-) diff --git a/crates/zedra-terminal/src/terminal.rs b/crates/zedra-terminal/src/terminal.rs index f31a092c..8d53c81a 100644 --- a/crates/zedra-terminal/src/terminal.rs +++ b/crates/zedra-terminal/src/terminal.rs @@ -325,16 +325,20 @@ impl Terminal { self.input_tx.clone() } - pub async fn send_bytes(&mut self, bytes: Vec) { - if let Some(tx) = &self.input_tx { - if let Err(e) = tx.send(bytes).await { - error!("failed to send input: {:?}", e) - } + pub async fn send_bytes(&mut self, bytes: Vec) -> bool { + let Some(tx) = &self.input_tx else { + return false; + }; + if let Err(e) = tx.send(bytes).await { + error!("failed to send input: {:?}", e); + return false; } + self.send_terminal_event(TerminalEvent::UserOutboundInput); + true } - pub async fn send_input(&mut self, text: String) { - self.send_bytes(text.into_bytes()).await; + pub async fn send_input(&mut self, text: String) -> bool { + self.send_bytes(text.into_bytes()).await } /// Feed bytes from PTY output buffer into the terminal emulator. @@ -4978,4 +4982,36 @@ mod tests { "protocol replies must not trigger post-input reclaim" ); } + + #[test] + fn user_async_send_bytes_emits_outbound_input_signal() { + use tokio::sync::mpsc; + futures::executor::block_on(async { + let (input_tx, mut input_rx) = mpsc::channel(4); + let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0)); + terminal.input_tx = Some(input_tx); + let mut events = terminal.subscribe_events(); + assert!(terminal.send_bytes(b"test".to_vec()).await); + let sent = input_rx.recv().await.expect("bytes sent"); + assert_eq!(sent, b"test"); + let event = events.try_recv().expect("outbound signal emitted"); + assert!(matches!(event, TerminalEvent::UserOutboundInput)); + }); + } + + #[test] + fn user_async_send_input_emits_outbound_input_signal() { + use tokio::sync::mpsc; + futures::executor::block_on(async { + let (input_tx, mut input_rx) = mpsc::channel(4); + let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0)); + terminal.input_tx = Some(input_tx); + let mut events = terminal.subscribe_events(); + assert!(terminal.send_input("text".to_string()).await); + let sent = input_rx.recv().await.expect("bytes sent"); + assert_eq!(sent, b"text"); + let event = events.try_recv().expect("outbound signal emitted"); + assert!(matches!(event, TerminalEvent::UserOutboundInput)); + }); + } } diff --git a/crates/zedra-terminal/src/view.rs b/crates/zedra-terminal/src/view.rs index a2339805..b5201c79 100644 --- a/crates/zedra-terminal/src/view.rs +++ b/crates/zedra-terminal/src/view.rs @@ -103,7 +103,6 @@ pub struct TerminalView { scroll_offset_px: f32, remote_scroll_offset_px: f32, keyboard_top_reveal_px: f32, - last_remote_size: Option<(u16, u16)>, /// Top-left origin of the painted terminal grid within the window. /// Used to turn touch scroll positions into terminal cell coordinates. grid_origin: Option>, @@ -197,7 +196,6 @@ impl TerminalView { scroll_offset_px: 0.0, remote_scroll_offset_px: 0.0, keyboard_top_reveal_px: 0.0, - last_remote_size: None, grid_origin: None, workdir: None, keyboard_inset: px(0.0), @@ -402,15 +400,7 @@ impl TerminalView { self.terminal.read(cx).size().into_remote_size() } - /// Reattach may not produce a paint mismatch, so sync the host PTY once. - pub fn sync_remote_size_after_attach(&mut self, cx: &mut Context) { - let remote_size = self.terminal.read(cx).size().into_remote_size(); - self.last_remote_size = Some(remote_size); - cx.emit(TerminalEvent::RequestResize { - cols: remote_size.0, - rows: remote_size.1, - }); - } + pub fn sync_remote_size_after_attach(&mut self, _cx: &mut Context) {} /// This is called by TerminalElement when the actual bounds of the terminal /// do not match the expected bounds. @@ -535,7 +525,6 @@ impl TerminalView { fn resize_remote_pty(&mut self, next: TerminalGridSize, cx: &mut Context) { let remote_size = next.remote_size(); - self.last_remote_size = Some(remote_size); cx.emit(TerminalEvent::RequestResize { cols: remote_size.0, rows: remote_size.1, @@ -811,7 +800,7 @@ impl TerminalView { self.outbound_input_signaled = false; } - fn take_pending_pinch_settle(&mut self) -> Option { + pub fn take_pending_pinch_settle(&mut self) -> Option { if !self.pinch_claimed { return None; } @@ -1375,29 +1364,20 @@ mod tests { } #[test] - fn sync_remote_size_after_attach_forces_resize_event() { + fn sync_remote_size_after_attach_does_not_emit_uncoordinated_resize_event() { let mut cx = TestAppContext::single(); let window = open_terminal_window(&mut cx); cx.run_until_parked(); let root = window.root(&mut cx).unwrap(); let mut events = cx.events(&root); - let expected_size = window + window .update(&mut cx, |terminal_view, _window, cx| { - let size = terminal_view.terminal.read(cx).size(); - let expected_size = (size.columns as u16, size.rows as u16); - terminal_view.last_remote_size = Some(expected_size); terminal_view.sync_remote_size_after_attach(cx); - expected_size }) .unwrap(); - match events.next().now_or_never().flatten() { - Some(TerminalEvent::RequestResize { cols, rows }) => { - assert_eq!((cols, rows), expected_size); - } - event => panic!("expected forced resize event, got {event:?}"), - } + assert!(events.next().now_or_never().flatten().is_none()); cx.quit(); } diff --git a/crates/zedra/src/workspace_terminal.rs b/crates/zedra/src/workspace_terminal.rs index 17b8b2be..d36adaf6 100644 --- a/crates/zedra/src/workspace_terminal.rs +++ b/crates/zedra/src/workspace_terminal.rs @@ -245,6 +245,9 @@ impl WorkspaceTerminal { self.scroll_to_bottom_button_hide_generation.wrapping_add(1); self.reclaim_epoch.reset(); self.terminal_view.update(cx, |terminal_view, cx| { + if let Some(settled) = terminal_view.take_pending_pinch_settle() { + crate::settings::set_terminal_font_size(settled.as_u8()); + } terminal_view.deactivate(cx); }); hide_native_floating_button(self.scroll_to_bottom_button_id); @@ -792,7 +795,6 @@ impl WorkspaceTerminal { Ok((input_tx, output_rx)) => { terminal_view.update(cx, |terminal_view, cx| { terminal_view.attach_channel(input_tx, output_rx, cx); - terminal_view.sync_remote_size_after_attach(cx); info!("attached channel to terminal"); }); true