aboutsummaryrefslogtreecommitdiff
path: root/src/app/runtime.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/runtime.rs')
-rw-r--r--src/app/runtime.rs752
1 files changed, 13 insertions, 739 deletions
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 8367a223..99769e7b 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -3,11 +3,8 @@ use std::time::Instant;
#[cfg(test)]
use std::time::Duration;
-use crossterm::terminal;
-
use super::{
background_update_check_enabled, App, AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL,
- RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL,
};
fn retain_detached_process_after_wait(
pid: u32,
@@ -31,10 +28,6 @@ impl App {
}
pub(crate) fn shutdown_terminal_runtime(&mut self, terminal_id: crate::terminal::TerminalId) {
- let target = super::TerminalInputTarget {
- terminal_id: terminal_id.clone(),
- };
- self.release_input_target_headless(&target);
if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) {
runtime.shutdown();
}
@@ -47,377 +40,6 @@ impl App {
}
}
- pub(crate) fn drain_api_requests(&mut self) -> bool {
- let mut changed = false;
- while let Ok(msg) = self.api_rx.try_recv() {
- changed |= self.handle_api_request_message(msg);
- self.shutdown_detached_terminal_runtimes();
- }
- changed
- }
-
- pub(super) fn handle_api_request_message(
- &mut self,
- msg: crate::api::ApiRequestMessage,
- ) -> bool {
- let previous_mode = self.state.mode;
- let stream_open = match &msg.request.method {
- crate::api::schema::Method::PaneGraphicsStreamOpen(params) => Some(params.clone()),
- _ => None,
- };
- let stream_active = msg.stream_active.clone();
- let mut changed = self.expire_due_metadata(Instant::now());
- changed |= crate::api::request_changes_ui(&msg.request);
- let skip_default_workspace = matches!(
- &msg.request.method,
- crate::api::schema::Method::ServerStop(_)
- | crate::api::schema::Method::ServerLiveHandoff(_)
- );
- if matches!(
- &msg.request.method,
- crate::api::schema::Method::WorktreeCreate(_)
- | crate::api::schema::Method::WorktreeRemove(_)
- ) {
- self.drain_all_internal_events();
- let deferred_changed =
- self.handle_deferred_worktree_api_request(msg.request, msg.respond_to);
- if !skip_default_workspace {
- changed |= self.ensure_default_workspace();
- }
- self.sync_prefix_input_source(previous_mode);
- return changed | deferred_changed;
- }
- let response = self.handle_api_request(msg.request);
- if let (Some(params), Some(active)) = (stream_open.as_ref(), stream_active) {
- self.attach_pane_graphics_stream_active(params, active, &response);
- }
- if !skip_default_workspace {
- changed |= self.ensure_default_workspace();
- }
- let _ = msg.respond_to.send(response);
- self.sync_prefix_input_source(previous_mode);
- changed
- }
-
- pub(super) async fn handle_raw_input_batch(
- &mut self,
- first: crate::raw_input::RawInputEvent,
- ) -> bool {
- let mut changed = self.handle_raw_input_event(first).await;
-
- while let Some(rx) = self.input_rx.as_mut() {
- match rx.try_recv() {
- Ok(event) => changed |= self.handle_raw_input_event(event).await,
- Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
- Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
- self.input_rx = None;
- break;
- }
- }
- }
-
- changed
- }
-
- async fn execute_repeat_plan(
- &mut self,
- lease_key: super::input::InputLeaseKey,
- key: crate::input::TerminalKey,
- plan: super::input::RepeatPlan,
- ) -> bool {
- match plan {
- super::input::RepeatPlan::Forwarded(target) => {
- if !self.forward_terminal_key_to_target(&target, key).await {
- self.input_leases.remove(&lease_key);
- }
- true
- }
- super::input::RepeatPlan::Reprocess {
- context,
- repetitions,
- tracked,
- } => {
- let key = key
- .with_kind(crossterm::event::KeyEventKind::Repeat)
- .with_repeat_count(1);
- let mut forwarded_target = None;
- for _ in 0..repetitions {
- if let Some(target) = &forwarded_target {
- if !self
- .forward_terminal_key_to_target(target, key.clone())
- .await
- {
- self.input_leases.remove(&lease_key);
- break;
- }
- continue;
- }
- let current_context = self.terminal_input_context();
- if !self.input_leases.reprocess_allowed(
- lease_key,
- &context,
- current_context.as_ref(),
- tracked,
- ) {
- break;
- }
- if let Some(target) = self.handle_key(key.clone()).await {
- if tracked {
- self.input_leases.insert_forwarded(
- lease_key,
- target.clone(),
- key.clone(),
- );
- forwarded_target = Some(target);
- }
- }
- }
- true
- }
- super::input::RepeatPlan::Ignore => false,
- }
- }
-
- pub(super) async fn handle_raw_input_event(
- &mut self,
- event: crate::raw_input::RawInputEvent,
- ) -> bool {
- let previous_mode = self.state.mode;
- let changed = match event {
- crate::raw_input::RawInputEvent::Key(key) => {
- let lease_key = super::input::InputLeaseKey::new(super::LOCAL_INPUT_SOURCE, &key);
- let key = self.input_leases.normalize_press(&lease_key, key);
- match key.kind {
- crossterm::event::KeyEventKind::Press => {
- let initial_context = self.terminal_input_context();
- let target = self.handle_key(key.clone()).await;
- let resulting_context = self.terminal_input_context();
- let plan = self.input_leases.complete_press(
- lease_key,
- &key,
- initial_context.as_ref(),
- resulting_context.as_ref(),
- target,
- );
- self.execute_repeat_plan(lease_key, key, plan).await;
- true
- }
- crossterm::event::KeyEventKind::Repeat => {
- let current_context = self.terminal_input_context();
- let plan = self.input_leases.plan_repeat(
- lease_key,
- &key,
- current_context.as_ref(),
- );
- self.execute_repeat_plan(lease_key, key, plan).await
- }
- crossterm::event::KeyEventKind::Release => {
- if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) {
- let _ = self
- .forward_terminal_key_to_target(&lease.target, key)
- .await;
- }
- false
- }
- }
- }
- crate::raw_input::RawInputEvent::Text(text) => {
- self.handle_text_commit(text.into_string()).await;
- true
- }
- crate::raw_input::RawInputEvent::Paste(text) => {
- self.handle_paste(text).await;
- true
- }
- crate::raw_input::RawInputEvent::Mouse(mouse) => {
- let changes_view = !matches!(mouse.kind, crossterm::event::MouseEventKind::Moved)
- || self.state.mode.mouse_motion_changes_view();
- if self.state.popup_pane.is_some() || self.state.mouse_capture {
- self.handle_mouse(mouse);
- } else {
- self.state
- .handle_pane_mouse_only(&self.terminal_runtimes, mouse);
- }
- changes_view
- }
- crate::raw_input::RawInputEvent::OuterFocusGained => {
- #[cfg(not(windows))]
- self.query_host_terminal_appearance();
- self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained);
- if self.state.redraw_on_focus_gained {
- self.request_repaint();
- }
- self.state.outer_terminal_focus = Some(true);
- self.state.mark_active_tab_seen();
- true
- }
- crate::raw_input::RawInputEvent::OuterFocusLost => {
- self.release_input_source(super::LOCAL_INPUT_SOURCE).await;
- self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost);
- self.state.outer_terminal_focus = Some(false);
- false
- }
- crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => {
- self.update_host_terminal_theme(kind, color)
- }
- crate::raw_input::RawInputEvent::HostPaletteColors { colors } => {
- self.update_host_terminal_palette_colors(&colors)
- }
- crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => {
- self.query_host_terminal_theme();
- self.set_host_terminal_appearance(appearance, true)
- }
- // Cell size reports are consumed by the thin client, not the runtime.
- crate::raw_input::RawInputEvent::HostCellSizeReport { .. } => false,
- crate::raw_input::RawInputEvent::Unsupported => false,
- };
- self.sync_prefix_input_source(previous_mode);
- self.shutdown_detached_terminal_runtimes();
- changed
- }
-
- fn handle_resize_poll(&mut self) -> bool {
- let Ok(size) = terminal::size() else {
- return false;
- };
- if self.last_terminal_size != Some(size) {
- self.last_terminal_size = Some(size);
- return true;
- }
- false
- }
-
- pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant, geometry_dirty: bool) -> bool {
- let mut changed = false;
- let mut resized = false;
-
- if now >= self.next_resize_poll {
- resized = self.handle_resize_poll();
- changed |= resized;
- self.next_resize_poll = now + RESIZE_POLL_INTERVAL;
- }
-
- if self
- .config_diagnostic_deadline
- .is_some_and(|deadline| now >= deadline)
- {
- self.config_diagnostic_deadline = None;
- self.state.config_diagnostic = None;
- changed = true;
- }
-
- if self.toast_deadline.is_some_and(|deadline| now >= deadline) {
- self.toast_deadline = None;
- self.state.toast = None;
- changed = true;
- }
-
- if self
- .state
- .next_pending_agent_notification_deadline()
- .is_some_and(|deadline| now >= deadline)
- {
- let previous_toast = self.state.toast.clone();
- let mut deliveries = self.state.drain_due_agent_notifications(now);
- if !deliveries.is_empty() {
- self.refresh_agent_notification_delivery_contexts(&mut deliveries);
- self.emit_delayed_client_local_agent_notifications(&deliveries);
- self.sync_toast_deadline(previous_toast);
- changed = true;
- }
- }
-
- if self
- .state
- .next_managed_agent_deadline()
- .is_some_and(|deadline| now >= deadline)
- {
- let panes = self.state.reconcile_managed_agents_at(now);
- if !panes.is_empty() {
- for (ws_idx, pane_id) in panes {
- self.emit_pane_updated(ws_idx, pane_id);
- }
- self.schedule_session_save();
- changed = true;
- }
- }
-
- if self
- .copy_feedback_deadline
- .is_some_and(|deadline| now >= deadline)
- {
- self.copy_feedback_deadline = None;
- self.state.copy_feedback = None;
- changed = true;
- }
-
- if self
- .selection_autoscroll_deadline
- .is_some_and(|deadline| now >= deadline)
- {
- self.tick_selection_autoscroll(now);
- changed = true;
- }
-
- changed |= self.clear_due_selection_highlight(now);
-
- self.start_git_status_refresh_if_due(now);
-
- if self
- .next_auto_update_check
- .is_some_and(|deadline| now >= deadline)
- {
- self.run_auto_update_check();
- }
-
- if self
- .next_agent_manifest_update_check
- .is_some_and(|deadline| now >= deadline)
- {
- self.run_agent_manifest_update_check();
- }
-
- if self
- .session_save_deadline
- .is_some_and(|deadline| now >= deadline)
- {
- self.start_background_session_save();
- }
-
- changed |= self.expire_due_metadata(now);
- changed |= self.handle_tab_bar_status_tasks(now);
-
- if geometry_dirty || resized {
- self.pending_agent_resume_deadline = None;
- } else {
- self.sync_pending_agent_resume_deadline(now);
- changed |= self.start_pending_agent_resumes(self.pending_agent_resume_due(now));
- }
- changed
- }
-
- /// Clears temporary copied-token highlights, such as after double-click copy.
- pub(crate) fn clear_due_selection_highlight(&mut self, now: Instant) -> bool {
- if self
- .selection_highlight_clear_deadline
- .is_none_or(|deadline| now < deadline)
- {
- return false;
- }
-
- self.selection_highlight_clear_deadline = None;
- if self
- .state
- .selection
- .as_ref()
- .is_some_and(|selection| !selection.is_in_progress())
- {
- self.state.clear_selection();
- return true;
- }
- false
- }
-
pub(crate) fn sync_agent_metadata_deadline(&mut self) {
self.agent_metadata_deadline = self.state.next_agent_metadata_expiry();
}
@@ -449,84 +71,6 @@ impl App {
self.sync_agent_metadata_deadline();
}
- pub(crate) fn tick_selection_autoscroll(&mut self, now: Instant) {
- let Some(autoscroll) = self.state.selection_autoscroll.clone() else {
- // Self-heal: state cleared but deadline leaked
- self.selection_autoscroll_deadline = None;
- return;
- };
-
- // Selection must still be in progress for autoscroll to continue
- let Some(pane_id) = self.state.selection.as_ref().map(|s| s.pane_id) else {
- self.stop_selection_autoscroll();
- return;
- };
- if !self
- .state
- .selection
- .as_ref()
- .is_some_and(|s| s.is_dragging())
- {
- self.stop_selection_autoscroll();
- return;
- }
-
- // Rect-change detection: if inner_rect changed since drag, stop
- let current_rect = self
- .state
- .pane_info_by_id(pane_id)
- .map(|info| info.inner_rect);
- if current_rect != Some(autoscroll.inner_rect) {
- self.stop_selection_autoscroll();
- return;
- }
-
- // Scrollback boundary detection via ScrollMetrics — fail-closed if unavailable
- let Some(metrics) = self
- .state
- .pane_scroll_metrics(&self.terminal_runtimes, pane_id)
- else {
- self.stop_selection_autoscroll();
- return;
- };
- match autoscroll.direction {
- crate::app::state::SelectionAutoscrollDirection::Up => {
- let at_top = metrics.offset_from_bottom >= metrics.max_offset_from_bottom;
- if at_top {
- self.stop_selection_autoscroll();
- return;
- }
- self.state
- .scroll_pane_up(&self.terminal_runtimes, pane_id, 1);
- }
- crate::app::state::SelectionAutoscrollDirection::Down => {
- let at_bottom = metrics.offset_from_bottom == 0;
- if at_bottom {
- self.stop_selection_autoscroll();
- return;
- }
- self.state
- .scroll_pane_down(&self.terminal_runtimes, pane_id, 1);
- }
- }
-
- // Extend selection cursor to last known mouse position
- self.state.update_selection_cursor(
- &self.terminal_runtimes,
- pane_id,
- autoscroll.last_mouse_screen_col,
- autoscroll.last_mouse_screen_row,
- );
-
- // Reschedule
- self.selection_autoscroll_deadline = Some(now + SELECTION_AUTOSCROLL_INTERVAL);
- }
-
- pub(crate) fn stop_selection_autoscroll(&mut self) {
- self.state.stop_selection_autoscroll_state();
- self.selection_autoscroll_deadline = None;
- }
-
pub(crate) fn can_render_now(&self, now: Instant) -> bool {
match self.last_render_at {
Some(last_render_at) => now.duration_since(last_render_at) >= MIN_RENDER_INTERVAL,
@@ -551,7 +95,10 @@ impl App {
}
pub(crate) fn run_auto_update_check(&mut self) {
- if !background_update_check_enabled(self.no_session, self.update_version_check_enabled) {
+ if !background_update_check_enabled(
+ self.policy.background_updates,
+ self.update_version_check_enabled,
+ ) {
self.next_auto_update_check = None;
return;
}
@@ -571,7 +118,10 @@ impl App {
}
pub(crate) fn run_agent_manifest_update_check(&mut self) {
- if !background_update_check_enabled(self.no_session, self.update_manifest_check_enabled) {
+ if !background_update_check_enabled(
+ self.policy.background_updates,
+ self.update_manifest_check_enabled,
+ ) {
self.next_agent_manifest_update_check = None;
return;
}
@@ -582,26 +132,12 @@ impl App {
std::thread::spawn(move || crate::detect::manifest_update::auto_update(manifest_update_tx));
}
- pub(crate) fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option<Instant> {
- self.next_loop_deadline_with_resize_poll(now, needs_render, true, true)
- }
-
pub(crate) fn next_headless_loop_deadline_with_git_refresh(
&self,
now: Instant,
needs_render: bool,
include_git_refresh: bool,
) -> Option<Instant> {
- self.next_loop_deadline_with_resize_poll(now, needs_render, false, include_git_refresh)
- }
-
- fn next_loop_deadline_with_resize_poll(
- &self,
- now: Instant,
- needs_render: bool,
- include_resize_poll: bool,
- include_git_refresh: bool,
- ) -> Option<Instant> {
let render_deadline = if needs_render {
self.last_render_at
.map(|last_render_at| last_render_at + MIN_RENDER_INTERVAL)
@@ -611,12 +147,10 @@ impl App {
};
[
- include_resize_poll.then_some(self.next_resize_poll),
self.config_diagnostic_deadline,
self.toast_deadline,
self.state.next_pending_agent_notification_deadline(),
self.state.next_managed_agent_deadline(),
- self.copy_feedback_deadline,
include_git_refresh
.then(|| self.git_refresh_deadline())
.flatten(),
@@ -625,8 +159,6 @@ impl App {
self.agent_metadata_deadline,
self.pending_agent_resume_deadline,
self.session_save_deadline,
- self.selection_autoscroll_deadline,
- self.selection_highlight_clear_deadline,
self.next_tab_bar_status_deadline(),
render_deadline,
]
@@ -635,11 +167,13 @@ impl App {
.min()
}
+ #[cfg(test)]
pub(crate) fn drain_internal_events(&mut self) -> bool {
self.drain_internal_events_up_to(super::APP_EVENT_DRAIN_LIMIT)
.1
}
+ #[cfg(test)]
pub(crate) fn drain_all_internal_events(&mut self) -> bool {
let mut changed = false;
loop {
@@ -653,6 +187,7 @@ impl App {
changed
}
+ #[cfg(test)]
fn drain_internal_events_up_to(&mut self, limit: usize) -> (bool, bool) {
let mut had_event = false;
let mut changed = false;
@@ -661,7 +196,7 @@ impl App {
break;
};
had_event = true;
- changed |= self.handle_internal_event_with_prefix_sync(ev);
+ changed |= self.handle_internal_event_with_render_impact(ev);
}
(had_event, changed)
}
@@ -670,7 +205,6 @@ impl App {
#[cfg(test)]
mod tests {
use super::*;
- use crate::app::state;
use crate::workspace::Workspace;
#[test]
@@ -697,7 +231,7 @@ mod tests {
fn test_app_with_pane() -> (super::super::App, crate::layout::PaneId) {
let mut app = super::super::App::new(
&crate::config::Config::default(),
- true,
+ crate::app::AppPolicy::TEST,
None,
tokio::sync::mpsc::unbounded_channel().1,
crate::api::EventHub::default(),
@@ -716,264 +250,4 @@ mod tests {
});
(app, pane_id)
}
-
- #[test]
- fn tick_selection_autoscroll_stops_when_metrics_unavailable() {
- // Without a runtime, pane_scroll_metrics returns None.
- // Fail-closed: stop autoscroll instead of rescheduling forever.
- let (mut app, pane_id) = test_app_with_pane();
- let now = Instant::now();
- let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
- // Drag to a different cell so it becomes Dragging
- sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None);
- app.state.selection = Some(sel);
- app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
- direction: state::SelectionAutoscrollDirection::Down,
- last_mouse_screen_col: 5,
- last_mouse_screen_row: 23,
- inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
- });
- app.selection_autoscroll_deadline = Some(now);
- app.tick_selection_autoscroll(now);
- // Should stop because no runtime metrics available
- assert!(app.state.selection_autoscroll.is_none());
- assert!(app.selection_autoscroll_deadline.is_none());
- }
-
- #[test]
- fn tick_selection_autoscroll_stops_when_selection_done() {
- let (mut app, pane_id) = test_app_with_pane();
- let now = Instant::now();
- // Create a selection that is already finished (not in progress)
- let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
- // Drag to a different cell so it becomes visible, then finish
- sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None);
- sel.finish(); // now it's Done, not in progress
- app.state.selection = Some(sel);
- app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
- direction: state::SelectionAutoscrollDirection::Down,
- last_mouse_screen_col: 0,
- last_mouse_screen_row: 23,
- inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
- });
- app.selection_autoscroll_deadline = Some(now);
- app.tick_selection_autoscroll(now);
- assert!(app.state.selection_autoscroll.is_none());
- assert!(app.selection_autoscroll_deadline.is_none());
- }
-
- #[test]
- fn tick_selection_autoscroll_stops_when_selection_cleared() {
- let (mut app, _pane_id) = test_app_with_pane();
- let now = Instant::now();
- app.state.selection = None;
- app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
- direction: state::SelectionAutoscrollDirection::Down,
- last_mouse_screen_col: 0,
- last_mouse_screen_row: 23,
- inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
- });
- app.selection_autoscroll_deadline = Some(now);
- app.tick_selection_autoscroll(now);
- assert!(app.state.selection_autoscroll.is_none());
- assert!(app.selection_autoscroll_deadline.is_none());
- }
-
- #[test]
- fn tick_selection_autoscroll_stops_when_selection_anchored() {
- // Anchored (click, no drag) should not keep the timer running.
- let (mut app, pane_id) = test_app_with_pane();
- let now = Instant::now();
- app.state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None));
- app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
- direction: state::SelectionAutoscrollDirection::Down,
- last_mouse_screen_col: 0,
- last_mouse_screen_row: 23,
- inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
- });
- app.selection_autoscroll_deadline = Some(now);
- app.tick_selection_autoscroll(now);
- assert!(app.state.selection_autoscroll.is_none());
- assert!(app.selection_autoscroll_deadline.is_none());
- }
-
- /// Creates an app with a real TerminalRuntime (no PTY) so scroll_metrics
- /// returns meaningful data. Uses test_with_scrollback_bytes.
- fn test_app_with_runtime(
- cols: u16,
- rows: u16,
- bytes: &[u8],
- ) -> (super::super::App, crate::layout::PaneId) {
- let mut app = super::super::App::new(
- &crate::config::Config::default(),
- true,
- None,
- tokio::sync::mpsc::unbounded_channel().1,
- crate::api::EventHub::default(),
- );
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let runtime =
- crate::terminal::TerminalRuntime::test_with_scrollback_bytes(cols, rows, 0, bytes);
- ws.tabs[0].runtimes.insert(pane_id, runtime);
- app.state.workspaces.push(ws);
- app.state.active = Some(0);
- app.state.view.pane_infos.push(crate::layout::PaneInfo {
- id: pane_id,
- rect: ratatui::layout::Rect::new(0, 0, cols, rows),
- inner_rect: ratatui::layout::Rect::new(0, 0, cols, rows),
- scrollbar_rect: None,
- borders: ratatui::widgets::Borders::NONE,
- is_focused: true,
- });
- (app, pane_id)
- }
-
- #[tokio::test]
- async fn tick_selection_autoscroll_stops_at_scrollback_top() {
- // Create a runtime with no scrollback content — we're already at
- // the top (offset_from_bottom == max_offset_from_bottom).
- let (mut app, pane_id) = test_app_with_runtime(80, 24, &[]);
- let now = Instant::now();
- let mut sel = crate::selection::Selection::anchor(pane_id, 5, 5, None);
- sel.drag(0, 0, ratatui::layout::Rect::new(0, 0, 80, 24), None);
- app.state.selection = Some(sel);
- app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
- direction: state::SelectionAutoscrollDirection::Up,
- last_mouse_screen_col: 0,
- last_mouse_screen_row: 0,
- inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
- });
- app.selection_autoscroll_deadline = Some(now);
- app.tick_selection_autoscroll(now);
- // At scrollback top, can't scroll further up — should stop
- assert!(app.state.selection_autoscroll.is_none());
- assert!(app.selection_autoscroll_deadline.is_none());
- }
-
- #[tokio::test]
- async fn tick_selection_autoscroll_stops_at_scrollback_bottom() {
- // Create a runtime with no scrollback content — we're already at
- // the bottom (offset_from_bottom == 0).
- let (mut app, pane_id) = test_app_with_runtime(80, 24, &[]);
- let now = Instant::now();
- let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
- sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None);
- app.state.selection = Some(sel);
- app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
- direction: state::SelectionAutoscrollDirection::Down,
- last_mouse_screen_col: 5,
- last_mouse_screen_row: 23,
- inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
- });
- app.selection_autoscroll_deadline = Some(now);
- app.tick_selection_autoscroll(now);
- // At scrollback bottom, can't scroll further down — should stop
- assert!(app.state.selection_autoscroll.is_none());
- assert!(app.selection_autoscroll_deadline.is_none());
- }
-
- #[tokio::test]
- async fn passive_mouse_motion_does_not_request_monolithic_render() {
- let (mut app, _) = test_app_with_pane();
- app.state.mode = crate::app::Mode::Terminal;
- let motion = || {
- crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent {
- kind: crossterm::event::MouseEventKind::Moved,
- column: 10,
- row: 5,
- modifiers: crossterm::event::KeyModifiers::empty(),
- })
- };
-
- assert!(!app.handle_raw_input_event(motion()).await);
- app.state.mode = crate::app::Mode::GlobalMenu;
- assert!(app.handle_raw_input_event(motion()).await);
- }
-
- #[tokio::test]
- async fn raw_input_batch_does_not_start_pending_agent_resume_before_render() {
- let (mut app, pane_id) = test_app_with_pane();
- app.state.ensure_test_terminals();
- let terminal_id = app.state.workspaces[0]
- .terminal_id(pane_id)
- .cloned()
- .expect("test pane should have a terminal");
- app.state
- .terminals
- .get_mut(&terminal_id)
- .expect("test terminal should exist")
- .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
- agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
- dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
- });
-
- assert!(
- app.handle_raw_input_batch(crate::raw_input::RawInputEvent::HostDefaultColor {
- kind: crate::terminal_theme::DefaultColorKind::Foreground,
- color: crate::terminal_theme::RgbColor {
- r: 220,
- g: 220,
- b: 220,
- },
- })
- .await
- );
- assert!(
- app.terminal_runtimes.get(&terminal_id).is_none(),
- "raw input can mutate active geometry; pending resumes must wait for render to refresh pane_infos"
- );
- assert!(app
- .state
- .terminals
- .get(&terminal_id)
- .expect("test terminal should still exist")
- .pending_agent_resume_plan
- .is_some());
- }
-
- #[tokio::test]
- async fn scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() {
- let (mut app, pane_id) = test_app_with_pane();
- app.state.ensure_test_terminals();
- app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme {
- foreground: Some(crate::terminal_theme::RgbColor {
- r: 220,
- g: 220,
- b: 220,
- }),
- background: Some(crate::terminal_theme::RgbColor {
- r: 20,
- g: 20,
- b: 20,
- }),
- ..Default::default()
- };
- let terminal_id = app.state.workspaces[0]
- .terminal_id(pane_id)
- .cloned()
- .expect("test pane should have a terminal");
- app.state
- .terminals
- .get_mut(&terminal_id)
- .expect("test terminal should exist")
- .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
- agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
- dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
- });
- app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1));
-
- assert!(!app.handle_scheduled_tasks(Instant::now(), true));
- assert!(app.terminal_runtimes.get(&terminal_id).is_none());
- assert!(app
- .state
- .terminals
- .get(&terminal_id)
- .expect("test terminal should still exist")
- .pending_agent_resume_plan
- .is_some());
- assert!(app.pending_agent_resume_deadline.is_none());
- }
}