diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/app/mod.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/app/mod.rs')
| -rw-r--r-- | src/app/mod.rs | 4139 |
1 files changed, 317 insertions, 3822 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index c28f62da..90ee33b8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2,7 +2,6 @@ //! //! - `state.rs` — AppState, Mode, and pure data structs //! - `actions.rs` — state mutations (testable without PTYs/async) -//! - `input.rs` — key/mouse → action translation pub(crate) mod actions; mod agent_resume; @@ -12,15 +11,13 @@ pub(crate) use agents::{AGENT_START_SETTLE_DELAY, MAX_AGENT_START_TIMEOUT}; mod api; mod api_helpers; pub(crate) use api_helpers::limit_snapshot_lines; -mod config_io; mod creation; +mod custom_commands; mod git_refresh; mod ids; -mod input; pub(crate) mod pane_graphics; mod popup; mod runtime; -mod runtime_mutations; mod session; pub mod state; mod tab_bar_status; @@ -30,31 +27,20 @@ mod theme_sync; mod window_title; mod worktrees; -use std::collections::{HashMap, HashSet}; -use std::future::pending; -use std::io::{self, Write}; +use std::collections::HashMap; +#[cfg(unix)] +use std::io; use std::sync::Arc; use std::time::{Duration, Instant}; const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); -pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30); -const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const GIT_REPO_DISCOVERY_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); const PENDING_AGENT_RESUME_THEME_WAIT: Duration = Duration::from_millis(750); const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5); -const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); -const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); -const PANE_COPY_HIGHLIGHT_DURATION: Duration = Duration::from_millis(500); -const COPY_FEEDBACK_DURATION: Duration = Duration::from_secs(2); - -use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, - execute, terminal, -}; + use ratatui::layout::Rect; -use ratatui::DefaultTerminal; use tokio::sync::{mpsc, Notify}; use tracing::info; @@ -80,21 +66,37 @@ pub(crate) struct OverlayPaneState { temp_files: Vec<std::path::PathBuf>, } -#[derive(Debug, Clone, Copy)] -pub(crate) struct PaneClickState { - pane_id: crate::layout::PaneId, - viewport_row: u16, - col: u16, - at: Instant, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AppPolicy { + pub(crate) restore_session: bool, + pub(crate) persist_session: bool, + pub(crate) persist_plugin_registry: bool, + pub(crate) background_updates: bool, } -impl PaneClickState { - fn is_double_click_for(self, next: Self) -> bool { - self.pane_id == next.pane_id - && next.at.duration_since(self.at) <= PANE_DOUBLE_CLICK_WINDOW - && self.viewport_row.abs_diff(next.viewport_row) <= 1 - && self.col.abs_diff(next.col) <= 1 - } +impl AppPolicy { + pub(crate) const PRODUCTION: Self = Self { + restore_session: true, + persist_session: true, + persist_plugin_registry: true, + background_updates: true, + }; + + #[cfg(test)] + pub(crate) const TEST: Self = Self { + restore_session: false, + persist_session: false, + persist_plugin_registry: false, + background_updates: false, + }; + + #[cfg(unix)] + pub(crate) const HANDOFF_REPLACEMENT: Self = Self { + restore_session: false, + persist_session: true, + persist_plugin_registry: true, + background_updates: true, + }; } pub struct App { @@ -109,12 +111,9 @@ pub struct App { pub(crate) api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>, pub(crate) event_hub: crate::api::EventHub, pub(crate) last_focus: Option<(usize, crate::layout::PaneId)>, - pub(crate) no_session: bool, - pub(crate) input_rx: Option<mpsc::Receiver<crate::raw_input::RawInputEvent>>, - pub(crate) last_terminal_size: Option<(u16, u16)>, + pub(crate) policy: AppPolicy, pub(crate) config_diagnostic_deadline: Option<Instant>, pub(crate) toast_deadline: Option<Instant>, - pub(crate) copy_feedback_deadline: Option<Instant>, pub(crate) last_api_notification_at: Option<Instant>, pub(crate) last_git_remote_status_refresh: Instant, pub(crate) last_git_repo_discovery_refresh: Instant, @@ -126,10 +125,6 @@ pub struct App { pub(crate) pending_api_worktree_removes: HashMap<String, u64>, pub(crate) pending_api_worktree_remove_paths: HashMap<std::path::PathBuf, u64>, pub(crate) next_api_worktree_operation_id: u64, - pub(crate) last_sidebar_divider_click: Option<Instant>, - pub(crate) last_pane_click: Option<PaneClickState>, - pub(crate) pending_url_click_sources: HashSet<InputSourceId>, - pub(crate) next_resize_poll: Instant, pub(crate) next_auto_update_check: Option<Instant>, pub(crate) next_agent_manifest_update_check: Option<Instant>, pub(crate) update_version_check_enabled: bool, @@ -137,8 +132,6 @@ pub struct App { pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig, pub(crate) agent_metadata_deadline: Option<Instant>, pub(crate) pending_agent_resume_deadline: Option<Instant>, - pub(crate) selection_autoscroll_deadline: Option<Instant>, - pub(crate) selection_highlight_clear_deadline: Option<Instant>, pub(crate) session_save_deadline: Option<Instant>, pub(crate) session_save_thread: Option<std::thread::JoinHandle<()>>, pub(crate) detached_process_children: Vec<std::process::Child>, @@ -153,91 +146,30 @@ pub struct App { pub(crate) last_render_at: Option<Instant>, /// Last attempt that could update a connected presentation surface. pub(crate) last_presentation_at: Option<Instant>, - pub(crate) input_leases: input::InputLeaseTable, pub render_notify: Arc<Notify>, pub(crate) render_dirty: Arc<crate::render_signal::RenderSignal>, pub(crate) full_redraw_pending: bool, pub(crate) overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>, - pub(crate) local_terminal_notifications: bool, - /// Whether this process applies `AppEvent::PrefixInputSource` to the host input source. - /// The headless server sets this to false: the switch belongs to the foreground client, - /// even when an App-internal drain consumes the event before the forwarding drain. - pub(crate) local_input_source_switch: bool, pub(crate) config_reloaded_from_disk: bool, - prefix_input_source: Box<dyn crate::platform::PrefixInputSource>, + client_shell_keybindings_profile: Option<String>, + endpoint_commands: custom_commands::EndpointCommandRegistry, } pub(crate) const APP_EVENT_CHANNEL_CAPACITY: usize = 256; pub(crate) const APP_EVENT_DRAIN_LIMIT: usize = 64; -pub(crate) enum LoopEvent { - Timer, - Internal(AppEvent), - Api(Box<crate::api::ApiRequestMessage>), - RawInput(crate::raw_input::RawInputEvent), - InputClosed, - RenderRequested, +fn auto_updates_enabled(background_updates: bool) -> bool { + background_updates && !cfg!(debug_assertions) } -struct SyncOutputGuard; - -impl SyncOutputGuard { - fn begin() -> io::Result<Self> { - let mut stdout = io::stdout().lock(); - stdout.write_all(b"\x1b[?2026h")?; - stdout.flush()?; - Ok(Self) - } +fn background_update_check_enabled(background_updates: bool, check_enabled: bool) -> bool { + auto_updates_enabled(background_updates) && check_enabled } -impl Drop for SyncOutputGuard { - fn drop(&mut self) { - let mut stdout = io::stdout().lock(); - let _ = stdout.write_all(b"\x1b[?2026l"); - let _ = stdout.flush(); - } -} - -async fn recv_raw_input_or_pending( - input_rx: Option<&mut mpsc::Receiver<crate::raw_input::RawInputEvent>>, -) -> Option<crate::raw_input::RawInputEvent> { - match input_rx { - Some(rx) => rx.recv().await, - None => pending().await, - } -} - -async fn sleep_until_or_pending(deadline: Option<Instant>) { - match deadline { - Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await, - None => pending().await, - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct TerminalInputTarget { - terminal_id: crate::terminal::TerminalId, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum TerminalInputContext { - Pane, - Popup(crate::terminal::TerminalId), -} - -pub(crate) type InputSourceId = u64; -const LOCAL_INPUT_SOURCE: InputSourceId = 0; - -fn auto_updates_enabled(no_session: bool) -> bool { - !no_session && !cfg!(debug_assertions) -} - -fn background_update_check_enabled(no_session: bool, check_enabled: bool) -> bool { - auto_updates_enabled(no_session) && check_enabled -} - -fn load_plugin_registry(no_session: bool) -> crate::app::state::InstalledPluginRegistry { - if no_session { +fn load_plugin_registry( + persist_plugin_registry: bool, +) -> crate::app::state::InstalledPluginRegistry { + if !persist_plugin_registry { return std::collections::HashMap::new(); } let entries = crate::persist::plugin_registry::load(); @@ -393,10 +325,33 @@ fn resolve_effective_theme( ) } +pub(crate) fn client_theme_runtime_from_config(config: &Config) -> state::ThemeRuntimeConfig { + theme_runtime_config(config, true) +} + +pub(crate) fn client_palette_for_theme( + runtime: &state::ThemeRuntimeConfig, + name: &str, +) -> state::Palette { + resolve_palette_for_theme_name(name, "catppuccin", runtime, None) +} + +pub(crate) fn client_palette_from_config(config: &Config) -> state::Palette { + let runtime = client_theme_runtime_from_config(config); + resolve_effective_theme(&runtime, None).0 +} + +pub(crate) fn client_palette_for_appearance( + runtime: &state::ThemeRuntimeConfig, + appearance: crate::terminal_theme::HostAppearance, +) -> state::Palette { + resolve_effective_theme(runtime, Some(appearance)).0 +} + impl App { pub fn new( config: &Config, - no_session: bool, + policy: AppPolicy, config_diagnostic: Option<String>, api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>, event_hub: crate::api::EventHub, @@ -410,24 +365,8 @@ impl App { // Try to restore previous session let mut restored_terminals = std::collections::HashMap::new(); let mut restored_terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let ( - workspaces, - active, - selected, - sidebar_width, - sidebar_width_source, - sidebar_section_split, - collapsed_space_keys, - ) = if no_session { - ( - Vec::new(), - None, - 0, - config.ui.sidebar_width, - state::SidebarWidthSource::ConfigDefault, - 0.5_f32, - std::collections::HashSet::new(), - ) + let (workspaces, active, selected) = if !policy.restore_session { + (Vec::new(), None, 0) } else if let Some(snap) = crate::persist::load() { let history = config .experimental @@ -451,67 +390,19 @@ impl App { restored_terminal_runtimes = terminal_runtimes.into(); if ws.is_empty() { crate::logging::session_restored(0, "empty"); - ( - Vec::new(), - None, - 0, - snap.sidebar_width.unwrap_or(config.ui.sidebar_width), - if snap.sidebar_width.is_some() { - state::SidebarWidthSource::Persisted - } else { - state::SidebarWidthSource::ConfigDefault - }, - snap.sidebar_section_split.unwrap_or(0.5), - snap.collapsed_space_keys, - ) + (Vec::new(), None, 0) } else { crate::logging::session_restored(ws.len(), "ok"); let active = snap.active.filter(|&i| i < ws.len()); let selected = snap.selected.min(ws.len().saturating_sub(1)); - ( - ws, - active, - selected, - snap.sidebar_width.unwrap_or(config.ui.sidebar_width), - if snap.sidebar_width.is_some() { - state::SidebarWidthSource::Persisted - } else { - state::SidebarWidthSource::ConfigDefault - }, - snap.sidebar_section_split.unwrap_or(0.5), - snap.collapsed_space_keys, - ) + (ws, active, selected) } } else { - ( - Vec::new(), - None, - 0, - config.ui.sidebar_width, - state::SidebarWidthSource::ConfigDefault, - 0.5_f32, - std::collections::HashSet::new(), - ) + (Vec::new(), None, 0) }; let agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); - // Validate sidebar bounds before they reach any `u16::clamp(min, max)` - // call: `clamp` panics when `min > max`. On bad config, fall back to - // the built-in defaults rather than crashing on the first render. - let (sidebar_min_width, sidebar_max_width) = crate::config::validated_sidebar_bounds( - config.ui.sidebar_min_width, - config.ui.sidebar_max_width, - ) - .unwrap_or_else(|| { - tracing::warn!( - min = config.ui.sidebar_min_width, - max = config.ui.sidebar_max_width, - "ui.sidebar_min_width is greater than sidebar_max_width; falling back to default bounds (18, 36)" - ); - (18, 36) - }); - let worktree_directory = crate::worktree::expand_tilde_absolute_path(&config.worktrees.directory); @@ -530,11 +421,7 @@ impl App { let startup_product_announcement = crate::product_announcements::load_unseen_for_current_version(); - let mode = if config.should_show_onboarding() { - state::Mode::Onboarding - } else if startup_product_announcement.is_some() { - state::Mode::ProductAnnouncement - } else if active.is_some() { + let mode = if active.is_some() { state::Mode::Terminal } else { state::Mode::Navigate @@ -560,33 +447,9 @@ impl App { selected, mode, should_quit: false, - detach_exits: no_session, - detach_requested: false, - request_new_workspace: false, - request_new_tab: false, - request_new_linked_worktree: None, - request_open_existing_worktree: None, - request_new_workspace_cwd: None, - request_remove_linked_worktree: None, - request_submit_worktree_create: false, - request_submit_worktree_open: false, - request_submit_worktree_remove: false, - request_reload_config: false, request_client_config_reload: false, - request_clipboard_write: None, - creating_new_tab: false, - requested_new_tab_name: None, - pending_workspace_create_cwd: None, - rename_pane_target: None, - worktree_create: None, - worktree_open: None, - worktree_remove: None, worktree_directory, - collapsed_space_keys, - request_complete_onboarding: false, - name_input: String::new(), - name_input_replace_on_type: false, - release_notes: None, + latest_release_notes, product_announcement: startup_product_announcement.map(|announcement| { state::ProductAnnouncementState { version: announcement.version, @@ -597,36 +460,10 @@ impl App { preview: announcement.preview, } }), - keybind_help: state::KeybindHelpState::default(), - navigator: state::NavigatorState::default(), - copy_mode: None, - workspace_scroll: 0, - agent_panel_scroll: 0, - tab_scroll: 0, - tab_scroll_follow_active: true, - mobile_switcher_scroll: 0, view: state::ViewState { - layout: state::ViewLayout::Desktop, - sidebar_rect: Rect::default(), - workspace_card_areas: Vec::new(), - tab_bar_rect: Rect::default(), - tab_hit_areas: Vec::new(), - tab_scroll_left_hit_area: Rect::default(), - tab_scroll_right_hit_area: Rect::default(), - new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), - mobile_header_rect: Rect::default(), - mobile_menu_hit_area: Rect::default(), - toast_hit_area: Rect::default(), pane_infos: Vec::new(), - split_borders: Vec::new(), }, - drag: None, - workspace_presses: HashMap::new(), - tab_presses: HashMap::new(), - selection: None, - selection_autoscroll: None, - context_menu: None, update_available, update_install_command, latest_release_notes_available, @@ -634,61 +471,33 @@ impl App { config_diagnostic, toast: None, pending_agent_notifications: std::collections::HashMap::new(), - copy_feedback: None, outer_terminal_focus: None, prefix_code, prefix_mods, headless_size: config.headless_size(), - default_sidebar_width: config.ui.sidebar_width, - sidebar_width, - sidebar_min_width, - sidebar_max_width, - mobile_width_threshold: config.ui.mobile_width_threshold, - sidebar_width_source, - sidebar_width_auto: false, - sidebar_collapsed: config.ui.sidebar_start_collapsed, - sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, - sidebar_section_split, agent_panel_sort, - status_indicators: config.ui.status_indicators, agent_view_override: None, sidebar_agents: config.ui.sidebar.agents.clone(), sidebar_spaces: config.ui.sidebar.spaces.clone(), next_agent_state_change_seq: 0, - mouse_capture: config.ui.mouse_capture, - copy_on_select: config.ui.copy_on_select, - right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(), - right_click_passthrough: None, - redraw_on_focus_gained: config.ui.redraw_on_focus_gained, - mouse_scroll_lines: config.ui.mouse_scroll_lines(), confirm_close: config.ui.confirm_close, - prompt_new_tab_name: config.ui.prompt_new_tab_name, - prompt_new_workspace_name: config.ui.prompt_new_workspace_name, pane_borders: config.ui.pane_borders, pane_outer_borders: config.ui.pane_outer_borders, pane_scrollbars: config.ui.pane_scrollbars, pane_gaps: config.ui.pane_gaps, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, - hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab, - tab_bar_position: config.ui.tab_bar_position, tab_bar_right: Vec::new(), tab_bar_right_separator: String::new(), - pane_history_persistence: config.experimental.pane_history, reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime, cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(), cjk_ime_agents: parse_cjk_ime_agents(&config.experimental.cjk_ime_agents), cjk_ime_cursor_shape: config.experimental.cjk_ime_cursor_shape.to_decscusr(), - switch_ascii_input_source_in_prefix: config - .experimental - .switch_ascii_input_source_in_prefix, kitty_graphics_enabled: config.experimental.kitty_graphics, default_shell: config.terminal.default_shell.clone(), shell_mode: config.terminal.shell_mode, new_terminal_cwd: config.terminal.new_cwd.clone(), pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes, - accent: crate::config::parse_color(&config.ui.accent), sound: config.ui.sound.clone(), - local_sound_playback: true, toast_config: config.ui.toast.clone(), keybinds: config.keybinds(), palette: theme_palette, @@ -696,29 +505,19 @@ impl App { theme_runtime, host_terminal_appearance: None, host_terminal_appearance_explicit: false, - settings: state::SettingsState { - section: state::SettingsSection::Theme, - list: state::SelectionListState::new(0), - original_palette: None, - original_theme: None, - }, integration_recommendations: crate::integration::integration_recommendations(), agent_manifest_summaries, agent_manifest_update_status: crate::detect::manifest_update::load_status(), - integration_install_messages: Vec::new(), - installed_plugins: load_plugin_registry(no_session), + installed_plugins: load_plugin_registry(policy.persist_plugin_registry), plugin_panes: std::collections::HashMap::new(), popup_pane: None, plugin_command_logs: Vec::new(), next_plugin_command_log_id: 1, plugin_commands_in_flight: 0, - global_menu: state::MenuListState::new(0), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), host_cell_size: crate::kitty_graphics::HostCellSize::default(), - host_mouse_pixels: None, session_dirty: false, terminal_runtime_shutdowns: Vec::new(), - confirm_close_workspace_id: None, }; state.terminals = restored_terminals; @@ -730,13 +529,15 @@ impl App { cwd.as_deref().and_then(crate::workspace::git_branch); } - // Background auto-update is disabled in monolithic no-session mode + // Background auto-update is disabled for non-persistent test apps // and in debug/test builds so local development never mutates the // running binary out from under spawned test processes. let version_check_enabled = - background_update_check_enabled(no_session, config.update.version_check); - let manifest_check_enabled = - background_update_check_enabled(no_session, config.update.manifest_check); + background_update_check_enabled(policy.background_updates, config.update.version_check); + let manifest_check_enabled = background_update_check_enabled( + policy.background_updates, + config.update.manifest_check, + ); if version_check_enabled { let update_tx = event_tx.clone(); std::thread::spawn(move || crate::update::auto_update(update_tx)); @@ -754,11 +555,13 @@ impl App { .get(idx) .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) }); + let client_shell_keybindings_profile = config.local_keybindings_profile_toml().ok(); + let endpoint_commands = + custom_commands::EndpointCommandRegistry::new(&state.keybinds.custom_commands); let mut app = Self { config_diagnostic_deadline: None, toast_deadline: None, - copy_feedback_deadline: None, last_api_notification_at: None, state, pane_graphics: pane_graphics::Runtime::default(), @@ -778,10 +581,6 @@ impl App { pending_api_worktree_removes: HashMap::new(), pending_api_worktree_remove_paths: HashMap::new(), next_api_worktree_operation_id: 1, - last_sidebar_divider_click: None, - last_pane_click: None, - pending_url_click_sources: HashSet::new(), - next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL, next_auto_update_check: version_check_enabled .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), next_agent_manifest_update_check: manifest_check_enabled @@ -799,26 +598,20 @@ impl App { tab_bar_commands: Vec::new(), next_tab_bar_datetime_refresh: None, window_title_template: None, - selection_autoscroll_deadline: None, - selection_highlight_clear_deadline: None, persist_pane_history: config.experimental.pane_history, last_render_at: None, last_presentation_at: None, - input_leases: input::InputLeaseTable::default(), api_rx, event_hub, last_focus, - no_session, - input_rx: None, - last_terminal_size: terminal::size().ok(), + policy, render_notify, render_dirty, full_redraw_pending: false, overlay_panes: HashMap::new(), - local_terminal_notifications: true, - local_input_source_switch: true, config_reloaded_from_disk: false, - prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()), + client_shell_keybindings_profile, + endpoint_commands, }; app.configure_tab_bar_status(&config.ui.tab_bar_right, &config.ui.tab_bar_right_separator); app.configure_window_title(&config.ui.window_title); @@ -837,7 +630,13 @@ impl App { crate::handoff_runtime::ImportedHandoffRuntime, >, ) -> io::Result<Self> { - let mut app = Self::new(config, true, config_diagnostic, api_rx, event_hub); + let mut app = Self::new( + config, + AppPolicy::HANDOFF_REPLACEMENT, + config_diagnostic, + api_rx, + event_hub, + ); let (workspaces, terminals, runtimes) = crate::persist::restore_handoff( snapshot, config.advanced.scrollback_limit_bytes, @@ -850,20 +649,6 @@ impl App { )?; let pane_id_aliases = crate::persist::handoff_pane_aliases(snapshot, &workspaces); - app.no_session = false; - app.state.installed_plugins = load_plugin_registry(app.no_session); - let now = Instant::now(); - if background_update_check_enabled(app.no_session, app.update_version_check_enabled) { - app.next_auto_update_check = app - .state - .update_available - .is_none() - .then_some(now + AUTO_UPDATE_CHECK_INTERVAL); - } - if background_update_check_enabled(app.no_session, app.update_manifest_check_enabled) { - app.next_agent_manifest_update_check = Some(now + AUTO_UPDATE_CHECK_INTERVAL); - } - app.state.detach_exits = false; app.state.pane_id_aliases = pane_id_aliases; app.state.workspaces = workspaces; app.state.terminals = terminals; @@ -874,14 +659,6 @@ impl App { app.state.selected = snapshot .selected .min(app.state.workspaces.len().saturating_sub(1)); - if let Some(width) = snapshot.sidebar_width { - app.state.sidebar_width = width; - app.state.sidebar_width_source = state::SidebarWidthSource::Persisted; - } - if let Some(split) = snapshot.sidebar_section_split { - app.state.sidebar_section_split = split; - } - app.state.collapsed_space_keys = snapshot.collapsed_space_keys.clone(); app.state.mode = if app.state.active.is_some() { state::Mode::Terminal } else { @@ -906,367 +683,15 @@ impl App { self.terminal_runtimes.assume_handoff_ownership(); } - fn request_repaint(&mut self) { - self.full_redraw_pending = true; - } - - pub(crate) fn sync_prefix_input_source(&mut self, previous_mode: Mode) { - // Emit the input-source intent on entering/leaving the ASCII realm, like `ClipboardWrite`; - // the foreground (client, or this app in monolithic mode) applies the switch. Keyed on the - // realm so multi-level prefix commands stay ASCII. The switch is flag-gated but the restore - // always fires on exit, so a mid-interaction flag toggle can't strand the host on ASCII. - let active = match ( - previous_mode.wants_ascii_input(), - self.state.mode.wants_ascii_input(), - ) { - (false, true) if self.state.switch_ascii_input_source_in_prefix => true, - (true, false) => false, - _ => return, - }; - if let Err(err) = self - .event_tx - .try_send(crate::events::AppEvent::PrefixInputSource { active }) - { - tracing::warn!(active, %err, "failed to queue prefix input-source change"); - } - } - - pub(crate) fn handle_internal_event_with_prefix_sync( - &mut self, - event: crate::events::AppEvent, - ) -> bool { - let previous_mode = self.state.mode; - let changed = self.handle_internal_event_with_render_impact(event); - self.sync_prefix_input_source(previous_mode); - changed - } - - #[cfg(test)] - pub(crate) fn set_prefix_input_source( - &mut self, - source: Box<dyn crate::platform::PrefixInputSource>, - ) { - self.prefix_input_source = source; - } - - pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { - if self.input_rx.is_none() { - self.input_rx = Some(crate::raw_input::spawn_input_reader()); - } - self.query_host_terminal_theme(); - - let mut needs_render = true; - let mut sent_window_title: Option<Option<String>> = None; - let mut host_mouse_capture_active = self.state.mouse_capture; - let mut host_keyboard_report_all_active = false; - - while !self.state.should_quit { - self.reap_finished_detached_processes(); - if self.render_dirty.is_pending() { - needs_render = true; - } - // Drain a bounded internal-event batch for responsiveness. API handlers - // perform an exhaustive drain before reading pane/runtime state. - if self.drain_internal_events() { - needs_render = true; - } - if self.expire_due_metadata(Instant::now()) { - needs_render = true; - } - if self.drain_api_requests() { - needs_render = true; - } - - self.sync_focus_events(); - self.sync_session_save_schedule(); - - let now = Instant::now(); - if self.handle_scheduled_tasks(now, needs_render) { - needs_render = true; - } - - if self.state.request_complete_onboarding { - self.state.request_complete_onboarding = false; - self.open_settings_from_onboarding(); - needs_render = true; - } - - if self.state.request_new_workspace { - self.state.request_new_workspace = false; - self.runtime_workspace_create( - "tui.workspace.create", - crate::api::schema::WorkspaceCreateParams { - cwd: None, - focus: true, - label: None, - env: Default::default(), - }, - ); - needs_render = true; - } - - if self.state.request_new_tab { - self.state.request_new_tab = false; - let label = self.state.requested_new_tab_name.take(); - self.runtime_tab_create( - "tui.tab.create", - crate::api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label, - env: Default::default(), - }, - ); - needs_render = true; - } - - if let Some(ws_idx) = self.state.request_new_linked_worktree.take() { - self.open_new_linked_worktree_dialog(ws_idx); - needs_render = true; - } - - if let Some(ws_idx) = self.state.request_open_existing_worktree.take() { - self.open_existing_worktree_dialog(ws_idx); - needs_render = true; - } - - if let Some(cwd) = self.state.request_new_workspace_cwd.take() { - self.runtime_workspace_create( - "tui.workspace.create_cwd", - crate::api::schema::WorkspaceCreateParams { - cwd: Some(cwd.display().to_string()), - focus: true, - label: None, - env: Default::default(), - }, - ); - needs_render = true; - } - - if let Some(ws_idx) = self.state.request_remove_linked_worktree.take() { - self.open_remove_linked_worktree_confirmation(ws_idx); - needs_render = true; - } - - if self.state.request_submit_worktree_create { - self.state.request_submit_worktree_create = false; - self.submit_worktree_create_via_api(); - needs_render = true; - } - - if self.state.request_submit_worktree_open { - self.state.request_submit_worktree_open = false; - self.submit_worktree_open_via_api(); - needs_render = true; - } - - if self.state.request_submit_worktree_remove { - self.state.request_submit_worktree_remove = false; - self.submit_worktree_remove_via_api(); - needs_render = true; - } - - if self.state.request_reload_config { - self.state.request_reload_config = false; - self.reload_config(); - needs_render = true; - } - - if self.ensure_default_workspace() { - needs_render = true; - } - - if self.pane_graphics.retain_live_panes(&self.state) { - needs_render = true; - } - - let now = Instant::now(); - self.render_dirty - .set_immediate_pty_sources(self.state.app_surface_pane_ids()); - self.sync_host_mouse_capture(&mut host_mouse_capture_active)?; - self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?; - - if needs_render && self.can_render_now(now) { - let render_request = self.render_dirty.take(); - self.sync_terminal_titles(&render_request.terminal_title_sources); - if self.window_title_configured() { - let title = self - .window_title() - .and_then(|title| crate::config::sanitize_window_title_text(&title)); - if sent_window_title.as_ref() != Some(&title) { - crate::terminal_effects::write_window_title( - &mut std::io::stdout(), - title.as_deref(), - )?; - sent_window_title = Some(title); - } - } - let _sync_output = SyncOutputGuard::begin()?; - let kitty_graphics_enabled = self.state.kitty_graphics_enabled; - if self.full_redraw_pending { - for cell in &mut terminal.current_buffer_mut().content { - cell.set_skip(true); - } - terminal.swap_buffers(); - self.full_redraw_pending = false; - } - let mut cell_size = self.state.host_cell_size; - terminal.draw(|frame| { - let area = frame.area(); - if kitty_graphics_enabled { - if let Some(observed_cell_size) = - crate::kitty_graphics::HostCellSize::try_from_terminal(area) - { - self.state.host_cell_size = observed_cell_size; - cell_size = observed_cell_size; - } else if !cell_size.is_known() { - cell_size = - crate::kitty_graphics::HostCellSize::fallback_for_area(area); - } - crate::ui::compute_view_with_cell_size( - &mut self.state, - &self.terminal_runtimes, - area, - cell_size, - ); - } else { - crate::ui::compute_view_with_runtime_registry( - &mut self.state, - &self.terminal_runtimes, - area, - ); - } - crate::ui::render_with_runtime_registry( - &self.state, - &self.terminal_runtimes, - frame, - ); - })?; - if kitty_graphics_enabled { - crate::kitty_graphics::paint_local_pane_graphics( - &self.state, - &self.pane_graphics, - &self.terminal_runtimes, - cell_size, - )?; - } - self.sync_pending_agent_resume_deadline(now); - if self.start_pending_agent_resumes(self.pending_agent_resume_due(now)) { - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - self.record_render_attempt(now, true); - needs_render = false; - continue; - } - - let next_deadline = self.next_loop_deadline(now, needs_render); - let event = { - let input_rx = self.input_rx.as_mut(); - tokio::select! { - maybe_api = self.api_rx.recv() => match maybe_api { - Some(msg) => LoopEvent::Api(Box::new(msg)), - None => LoopEvent::Timer, - }, - maybe_ev = self.event_rx.recv() => match maybe_ev { - Some(ev) => LoopEvent::Internal(ev), - None => LoopEvent::Timer, - }, - maybe_input = recv_raw_input_or_pending(input_rx) => match maybe_input { - Some(input) => LoopEvent::RawInput(input), - None => LoopEvent::InputClosed, - }, - _ = sleep_until_or_pending(next_deadline) => LoopEvent::Timer, - _ = self.render_notify.notified() => LoopEvent::RenderRequested, - } - }; - - match event { - LoopEvent::Timer => {} - LoopEvent::Internal(ev) => { - if self.handle_internal_event_with_prefix_sync(ev) { - needs_render = true; - } - } - LoopEvent::Api(msg) => { - if self.handle_api_request_message(*msg) { - needs_render = true; - } - } - LoopEvent::RawInput(input) => { - if self.handle_raw_input_batch(input).await { - needs_render = true; - } - } - LoopEvent::InputClosed => { - self.input_rx = None; - } - LoopEvent::RenderRequested => { - if self.render_dirty.is_pending() { - needs_render = true; - } - } - } - } - - // Save session on exit (skip in --no-session mode) - if !self.no_session { - self.save_session_now(); - } - - Ok(()) - } - - fn sync_host_keyboard_report_all(&self, active: &mut bool) -> io::Result<()> { - let desired = self.host_keyboard_report_all_requested(); - if desired == *active { - return Ok(()); - } - crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired)?; - *active = desired; - Ok(()) - } - - fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> { - let desired = self - .state - .should_capture_host_mouse_from(&self.terminal_runtimes); - if desired == *active { - return Ok(()); - } - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; - if desired { - execute!(io::stdout(), EnableMouseCapture)?; - } else { - execute!(io::stdout(), DisableMouseCapture)?; - } - *active = desired; - Ok(()) - } - pub(crate) fn ensure_default_workspace(&mut self) -> bool { - if !self.state.workspaces.is_empty() - || self.state.mode == Mode::Onboarding - || self.state.pending_workspace_create_cwd.is_some() - { + if !self.state.workspaces.is_empty() { return false; } - let previous_mode = self.state.mode; - let preserve_mode = matches!( - previous_mode, - Mode::ReleaseNotes | Mode::ProductAnnouncement | Mode::Settings - ); let cwd = self.resolve_new_terminal_cwd(None); match self.create_workspace_with_options(cwd, true) { - Ok(_) => { - if preserve_mode { - self.state.mode = previous_mode; - } - true - } + Ok(_) => true, Err(err) => { tracing::error!(err = %err, "failed to create default workspace"); self.state.mode = Mode::Navigate; @@ -1275,14 +700,7 @@ impl App { } } - pub(crate) fn dismiss_release_notes(&mut self) { - let preview = self - .state - .release_notes - .as_ref() - .is_some_and(|notes| notes.preview); - - self.state.release_notes = None; + fn mark_release_notes_seen(&mut self, preview: bool) { if !preview { if let Err(err) = crate::release_notes::mark_current_version_seen() { self.state.config_diagnostic = @@ -1290,16 +708,6 @@ impl App { self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5)); } } - - if self.state.product_announcement.is_some() { - self.state.mode = Mode::ProductAnnouncement; - } else { - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } } pub(crate) fn dismiss_product_announcement(&mut self) { @@ -1314,87 +722,6 @@ impl App { } } } - - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - pub(crate) fn scroll_release_notes(&mut self, delta: i16) { - let max_scroll = self.state.release_notes_max_scroll(); - if let Some(notes) = &mut self.state.release_notes { - notes.scroll = if delta.is_negative() { - notes.scroll.saturating_sub(delta.unsigned_abs()) - } else { - notes.scroll.saturating_add(delta as u16) - } - .min(max_scroll); - } - } - - pub(crate) fn scroll_product_announcement(&mut self, delta: i16) { - let max_scroll = self.state.product_announcement_max_scroll(); - if let Some(announcement) = &mut self.state.product_announcement { - announcement.scroll = if delta.is_negative() { - announcement.scroll.saturating_sub(delta.unsigned_abs()) - } else { - announcement.scroll.saturating_add(delta as u16) - } - .min(max_scroll); - } - } - - pub(crate) fn open_settings_from_onboarding(&mut self) { - self.mark_onboarding_complete(); - self.refresh_integration_recommendations(); - crate::app::input::open_settings_at(&mut self.state, state::SettingsSection::Integrations); - } - - pub(crate) fn refresh_integration_recommendations(&mut self) { - self.state.integration_recommendations = crate::integration::integration_recommendations(); - } - - pub(crate) fn install_recommended_integrations(&mut self) { - let targets = self - .state - .integration_recommendations - .iter() - .filter(|recommendation| recommendation.needs_install()) - .map(|recommendation| recommendation.target) - .collect::<Vec<_>>(); - - self.state.integration_install_messages.clear(); - if targets.is_empty() { - self.state - .integration_install_messages - .push("all detected integrations are current".to_string()); - return; - } - - for target in targets { - let label = crate::integration::integration_target_label(target); - match crate::integration::install_target(target) { - Ok(messages) => { - self.state - .integration_install_messages - .push(format!("installed {label}")); - self.state - .integration_install_messages - .extend(messages.into_iter().filter(|message| { - message.starts_with(crate::integration::INSTALL_WARNING_PREFIX) - })); - } - Err(err) => self - .state - .integration_install_messages - .push(format!("{label}: {err}")), - } - } - - self.state.integration_recommendations = crate::integration::integration_recommendations(); - self.state.mark_session_dirty(); } pub(crate) fn reload_config(&mut self) -> crate::config::ConfigReloadReport { @@ -1431,6 +758,8 @@ impl App { } } }; + self.endpoint_commands = + custom_commands::EndpointCommandRegistry::new(&self.state.keybinds.custom_commands); self.sync_toast_deadline(previous_toast); report } @@ -1452,6 +781,12 @@ impl App { self.state.prefix_code = live.prefix.0; self.state.prefix_mods = live.prefix.1; self.state.keybinds = live.keybinds; + match config.local_keybindings_profile_toml() { + Ok(profile) => self.client_shell_keybindings_profile = Some(profile), + Err(err) => diagnostics.push(format!( + "failed to publish server keybindings: {err}; kept previous keybindings" + )), + } diagnostics.extend(keybind_diagnostics); } Err(keybind_diagnostics) => { @@ -1480,44 +815,14 @@ impl App { &config.ui.window_title, )); - self.state.default_sidebar_width = config.ui.sidebar_width; - if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault { - self.state.sidebar_width = config.ui.sidebar_width; - } - self.state.sidebar_min_width = config.ui.sidebar_min_width; - self.state.sidebar_max_width = config.ui.sidebar_max_width; - self.state.sidebar_collapsed_mode = config.ui.sidebar_collapsed_mode; - self.state.mobile_width_threshold = config.ui.mobile_width_threshold; - // Re-clamp the live width to the new bounds. No source guard — bounds - // always apply, including to widths owned by Persisted or Manual. - self.state.sidebar_width = self - .state - .sidebar_width - .clamp(self.state.sidebar_min_width, self.state.sidebar_max_width); - self.state.mouse_capture = config.ui.mouse_capture; - self.state.copy_on_select = config.ui.copy_on_select; - if self.state.redraw_on_focus_gained != config.ui.redraw_on_focus_gained { - self.state.request_client_config_reload = true; - } - self.state.redraw_on_focus_gained = config.ui.redraw_on_focus_gained; - if self.loaded_host_cursor != config.ui.host_cursor { - self.state.request_client_config_reload = true; - } self.loaded_host_cursor = config.ui.host_cursor; - self.state.mouse_scroll_lines = config.ui.mouse_scroll_lines(); - self.state.right_click_passthrough_modifiers = - config.ui.right_click_passthrough_modifiers(); self.state.confirm_close = config.ui.confirm_close; - self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name; - self.state.prompt_new_workspace_name = config.ui.prompt_new_workspace_name; self.state.pane_borders = config.ui.pane_borders; self.state.pane_outer_borders = config.ui.pane_outer_borders; self.state.pane_scrollbars = config.ui.pane_scrollbars; self.state.pane_gaps = config.ui.pane_gaps; self.state.show_agent_labels_on_pane_borders = config.ui.show_agent_labels_on_pane_borders; - self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab; - self.state.tab_bar_position = config.ui.tab_bar_position; self.configure_tab_bar_status( &config.ui.tab_bar_right, &config.ui.tab_bar_right_separator, @@ -1525,14 +830,8 @@ impl App { self.configure_window_title(&config.ui.window_title); self.state.agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); - self.state.status_indicators = config.ui.status_indicators; self.state.sidebar_agents = config.ui.sidebar.agents.clone(); self.state.sidebar_spaces = config.ui.sidebar.spaces.clone(); - self.state.agent_panel_scroll = 0; - self.state.accent = crate::config::parse_color(&config.ui.accent); - if !self.state.local_sound_playback && self.state.sound != config.ui.sound { - self.state.request_client_config_reload = true; - } self.state.sound = config.ui.sound.clone(); self.state.toast_config = config.ui.toast.clone(); } @@ -1543,7 +842,6 @@ impl App { self.state.kitty_graphics_enabled = config.experimental.kitty_graphics; crate::kitty_graphics::set_enabled(config.experimental.kitty_graphics); if was_kitty_graphics_enabled && !config.experimental.kitty_graphics { - let _ = crate::kitty_graphics::clear_all_host_graphics(); self.pane_graphics.clear(); self.state.host_cell_size = crate::kitty_graphics::HostCellSize::default(); } @@ -1554,10 +852,7 @@ impl App { self.state.cjk_ime_agents = parse_cjk_ime_agents(&config.experimental.cjk_ime_agents); self.state.cjk_ime_cursor_shape = config.experimental.cjk_ime_cursor_shape.to_decscusr(); - self.state.switch_ascii_input_source_in_prefix = - config.experimental.switch_ascii_input_source_in_prefix; self.persist_pane_history = config.experimental.pane_history; - self.state.pane_history_persistence = config.experimental.pane_history; if !self.persist_pane_history { crate::persist::clear_history(); } @@ -1586,7 +881,7 @@ impl App { self.next_auto_update_check = None; } else if !previous_version_check_enabled && background_update_check_enabled( - self.no_session, + self.policy.background_updates, self.update_version_check_enabled, ) && self.state.update_available.is_none() @@ -1598,7 +893,7 @@ impl App { self.next_agent_manifest_update_check = None; } else if !previous_manifest_check_enabled && background_update_check_enabled( - self.no_session, + self.policy.background_updates, self.update_manifest_check_enabled, ) { @@ -1654,459 +949,27 @@ impl App { } } + self.state.request_client_config_reload = true; crate::config::ConfigReloadReport { status, diagnostics, } } } - -// --------------------------------------------------------------------------- -// Input routing for headless server mode -// --------------------------------------------------------------------------- - -impl App { - pub(crate) fn terminal_input_context(&self) -> Option<TerminalInputContext> { - if let Some(popup) = &self.state.popup_pane { - Some(TerminalInputContext::Popup(popup.terminal_id.clone())) - } else if self.state.mode == Mode::Terminal { - Some(TerminalInputContext::Pane) - } else { - None - } - } - - fn execute_repeat_plan_headless( - &mut self, - source_id: InputSourceId, - lease_key: input::InputLeaseKey, - key: crate::input::TerminalKey, - plan: input::RepeatPlan, - ) { - match plan { - input::RepeatPlan::Forwarded(target) => { - if !self.forward_terminal_key_to_target_headless(&target, key) { - self.input_leases.remove(&lease_key); - } - } - 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_headless(target, key.clone()) { - 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_terminal_key_headless_from(source_id, key.clone()) - { - if tracked { - self.input_leases.insert_forwarded( - lease_key, - target.clone(), - key.clone(), - ); - forwarded_target = Some(target); - } - } - } - } - input::RepeatPlan::Ignore => {} - } - } - - /// Routes raw input bytes from a client through the existing input pipeline. - /// - /// The input bytes are parsed into `RawInputEvent`s and then processed. - /// In terminal mode, keys are routed through the same semantic - /// key-handling path as monolithic herdr so they are re-encoded for the - /// focused pane's negotiated keyboard protocol instead of passing host - /// terminal escape sequences through unchanged. - #[cfg(test)] - pub(crate) fn route_client_input(&mut self, data: Vec<u8>) { - let events = crate::raw_input::parse_raw_input_bytes_sync(&data); - self.route_client_events(events, true); - } - - pub(crate) fn route_client_pixel_mouse( - &mut self, - source_id: InputSourceId, - data: &[u8], - geometry: crate::input::mouse::HostGeometry, - ) -> bool { - let Some((x, y)) = crate::input::mouse::parse_report(data) else { - return false; - }; - let Some((column, row)) = geometry.cell(x, y) else { - return false; - }; - let Some(cell_report) = crate::input::mouse::report_at_cell(data, column, row) else { - return false; - }; - let mut events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); - if events.len() != 1 || !matches!(events[0], crate::raw_input::RawInputEvent::Mouse(_)) { - return false; - } - self.state.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry }); - self.route_client_events_from(source_id, std::mem::take(&mut events), false); - self.state.host_mouse_pixels = None; - true - } - - pub(crate) fn route_client_events( - &mut self, - events: Vec<crate::raw_input::RawInputEvent>, - apply_host_terminal_theme: bool, - ) { - self.route_client_events_from(LOCAL_INPUT_SOURCE, events, apply_host_terminal_theme); - } - - pub(crate) fn route_client_events_from( - &mut self, - source_id: InputSourceId, - events: Vec<crate::raw_input::RawInputEvent>, - apply_host_terminal_theme: bool, - ) { - for event in events { - let previous_mode = self.state.mode; - match event { - crate::raw_input::RawInputEvent::Key(key) => { - let lease_key = input::InputLeaseKey::new(source_id, &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 = if initial_context.is_some() { - self.handle_terminal_key_headless_from(source_id, key.clone()) - } else { - self.handle_non_terminal_key_headless(key.clone()); - None - }; - 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_headless(source_id, lease_key, key, plan); - } - 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_headless(source_id, lease_key, key, plan); - } - crossterm::event::KeyEventKind::Release => { - if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { - let _ = self - .forward_terminal_key_to_target_headless(&lease.target, key); - } - } - } - } - crate::raw_input::RawInputEvent::Text(text) => { - self.handle_text_commit_headless(text.as_str()); - } - crate::raw_input::RawInputEvent::Mouse(mouse) => { - if self.state.popup_pane.is_some() || self.state.mouse_capture { - self.handle_mouse_event_headless(source_id, mouse); - } else { - self.state - .handle_pane_mouse_only(&self.terminal_runtimes, mouse); - } - } - crate::raw_input::RawInputEvent::Paste(text) => { - if self.try_route_paste_to_popup(&text) { - } else if self.state.mode != Mode::Terminal { - self.paste_into_active_text_input(&text); - } else { - if let Some(ws_idx) = self.state.active { - if let Some(ws) = self.state.workspaces.get(ws_idx) { - if let Some(focused) = ws.focused_pane_id() { - if let Some(runtime) = self.state.runtime_for_pane_in_workspace( - &self.terminal_runtimes, - ws_idx, - focused, - ) { - let _ = runtime.try_send_paste(text); - } - } - } - } - } - } - crate::raw_input::RawInputEvent::OuterFocusGained => { - self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained); - } - crate::raw_input::RawInputEvent::OuterFocusLost => { - self.release_input_source_headless(source_id); - self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost); - } - crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { - if apply_host_terminal_theme { - self.update_host_terminal_theme(kind, color); - } - } - crate::raw_input::RawInputEvent::HostPaletteColors { colors } => { - if apply_host_terminal_theme { - self.update_host_terminal_palette_colors(&colors); - } - } - crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { - if apply_host_terminal_theme { - self.set_host_terminal_appearance(appearance, true); - } - } - crate::raw_input::RawInputEvent::HostCellSizeReport { .. } => {} - crate::raw_input::RawInputEvent::Unsupported => {} - } - self.sync_prefix_input_source(previous_mode); - } - } - - pub(crate) fn clear_input_source(&mut self, source_id: InputSourceId) { - // Call this only when the input source is gone for good. A pending URL - // click has to outlive a plain focus change, because opening the URL - // raises the browser and costs the host terminal its focus before the - // mouse release arrives. - self.pending_url_click_sources.remove(&source_id); - self.release_input_source_headless(source_id); - } - - /// Handles a key event in non-terminal mode for the headless server. - /// - /// Uses the standalone handler functions that work on `&mut AppState` - /// since the server doesn't have the async context of the monolithic App. - fn handle_non_terminal_key_headless(&mut self, key: crate::input::TerminalKey) { - let key_event = key.as_key_event(); - if input::modal_paste_target_active(&self.state) - && input::is_modal_paste_shortcut(&key_event) - { - if let Some(text) = crate::platform::read_clipboard_text() { - self.paste_into_active_text_input(&text); - } - return; - } - - match self.state.mode { - Mode::Prefix => { - self.handle_prefix_key(key); - } - Mode::Navigate => { - self.handle_navigate_key(key); - } - Mode::Copy => { - self.handle_copy_mode_key(key); - } - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { - self.handle_rename_key_via_api(key_event); - } - Mode::NewLinkedWorktree => { - self.handle_worktree_create_key(key_event); - } - Mode::OpenExistingWorktree => { - self.handle_worktree_open_key(key_event); - } - Mode::ConfirmRemoveWorktree => { - self.handle_worktree_remove_key(key_event); - } - Mode::Resize => { - self.handle_resize_key_via_api(key); - } - Mode::ConfirmClose => { - self.handle_confirm_close_key_via_api(key_event); - } - Mode::ContextMenu => { - self.handle_context_menu_key_via_api(key_event); - } - Mode::KeybindHelp => { - input::handle_keybind_help_key(&mut self.state, key); - } - Mode::GlobalMenu => { - input::handle_global_menu_key(&mut self.state, key_event); - } - Mode::Onboarding => { - self.handle_onboarding_key(key_event); - } - Mode::ReleaseNotes => { - self.handle_release_notes_key(key_event); - } - Mode::ProductAnnouncement => { - self.handle_product_announcement_key(key_event); - } - Mode::Settings => { - self.handle_settings_key(key_event); - } - Mode::Navigator => { - input::handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event); - } - Mode::Terminal => { - // Should not be called in terminal mode. - } - } - } - - /// Handles a mouse event for the headless server. - /// - /// Delegates to the same mouse handling logic used in the monolithic - /// mode (hit-testing against the rendered UI), which works because - /// the server's AppState maintains view geometry from virtual rendering. - fn handle_mouse_event_headless( - &mut self, - source_id: InputSourceId, - mouse: crossterm::event::MouseEvent, - ) { - self.handle_mouse_from_input_source(source_id, mouse); - } -} - #[cfg(test)] mod tests { use super::*; use crate::config::Config; use crate::detect::{Agent, AgentState}; - use crate::terminal::TerminalRuntime; use crate::workspace::Workspace; - use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; - use std::cell::Cell; - use std::rc::Rc; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::sync::Mutex; - fn raw_key( - code: KeyCode, - modifiers: KeyModifiers, - kind: KeyEventKind, - ) -> crate::raw_input::RawInputEvent { - crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new(code, modifiers).with_kind(kind), - ) - } - - #[cfg(windows)] - #[tokio::test] - async fn native_repeats_and_releases_follow_the_pressed_pane() { - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 27, - virtual_scan_code: 1, - unicode: 27, - control_key_state: 0, - }; - let key = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) - .with_windows_record(record); - let enhanced = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) - .with_windows_record(crate::input::WindowsKeyRecord { - control_key_state: 0x0100, - ..record - }); - assert_ne!( - input::InputLeaseKey::new(7, &key), - input::InputLeaseKey::new(7, &enhanced) - ); - - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let pressed_pane = workspace.focused_pane_id().unwrap(); - let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(pressed_pane); - let (pressed_runtime, mut pressed_rx) = - TerminalRuntime::test_with_channel_capacity(80, 24, 6); - let (other_runtime, mut other_rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 6); - workspace.tabs[0] - .runtimes - .insert(pressed_pane, pressed_runtime); - workspace.tabs[0].runtimes.insert(other_pane, other_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let release = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) - .with_windows_record(crate::input::WindowsKeyRecord { - key_down: false, - repeat_count: 1, - unicode: 0, - ..record - }) - .with_kind(KeyEventKind::Release); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(key.clone())], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, other_pane)); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key(key.clone()), - crate::raw_input::RawInputEvent::Key(key.clone().with_kind(KeyEventKind::Repeat)), - crate::raw_input::RawInputEvent::Key(release), - crate::raw_input::RawInputEvent::Key(key.clone()), - crate::raw_input::RawInputEvent::OuterFocusLost, - ], - false, - ); - - for _ in 0..3 { - assert_eq!( - pressed_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") - ); - } - assert_eq!( - pressed_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;0;0;0;1_") - ); - assert!(pressed_rx.try_recv().is_err()); - - assert_eq!( - other_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") - ); - assert_eq!( - other_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;27;0;0;1_") - ); - assert!(other_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - fn release_notes_state() -> state::ReleaseNotesState { - state::ReleaseNotesState { - version: "0.1.0".into(), - body: "notes".into(), - scroll: 0, - preview: true, - } - } - fn test_app() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2131,230 +994,6 @@ mod tests { "/usr/bin/true" } - #[derive(Clone, Default)] - struct FakePrefixInputSource { - switch_calls: Rc<Cell<usize>>, - restore_calls: Rc<Cell<usize>>, - switched: Rc<Cell<bool>>, - will_switch: bool, - } - - impl FakePrefixInputSource { - fn switching() -> Self { - Self { - will_switch: true, - ..Self::default() - } - } - - fn no_op() -> Self { - Self { - will_switch: false, - ..Self::default() - } - } - } - - impl crate::platform::PrefixInputSource for FakePrefixInputSource { - fn switch_to_ascii(&mut self) { - self.switch_calls.set(self.switch_calls.get() + 1); - if self.will_switch { - self.switched.set(true); - } - } - - fn restore(&mut self) { - if self.switched.replace(false) { - self.restore_calls.set(self.restore_calls.get() + 1); - } - } - } - - /// Drain the app event channel, returning the `active` flags of any emitted - /// `PrefixInputSource` events (the host-local input-source intents). - fn drained_prefix_active(app: &mut App) -> Vec<bool> { - let mut out = Vec::new(); - while let Ok(ev) = app.event_rx.try_recv() { - if let crate::events::AppEvent::PrefixInputSource { active } = ev { - out.push(active); - } - } - out - } - - #[test] - fn sync_prefix_input_source_emits_switch_then_restore_when_enabled() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - - // Terminal -> Prefix emits the ASCII-switch intent. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Prefix -> Terminal emits the restore intent. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn sync_prefix_input_source_does_not_emit_switch_when_flag_disabled() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = false; - - // Entering the realm with the flag off emits nothing. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert!(drained_prefix_active(&mut app).is_empty()); - - // Leaving the realm still emits the restore (harmless if nothing was switched), so a - // mid-interaction flag toggle can't strand the host on ASCII. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn mode_wants_ascii_input_classification() { - // Allowlist: the prefix command/navigation realm wants ASCII. - for mode in [ - Mode::Prefix, - Mode::Navigate, - Mode::Navigator, - Mode::Copy, - Mode::Resize, - Mode::ConfirmClose, - Mode::ConfirmRemoveWorktree, - Mode::ContextMenu, - Mode::GlobalMenu, - Mode::KeybindHelp, - ] { - assert!(mode.wants_ascii_input(), "{mode:?} should want ASCII"); - } - // Everything else (terminal, text entry, startup overlays) keeps the user's IME. - for mode in [ - Mode::Terminal, - Mode::RenameWorkspace, - Mode::RenameTab, - Mode::RenamePane, - Mode::NewLinkedWorktree, - Mode::OpenExistingWorktree, - Mode::Settings, - Mode::Onboarding, - Mode::ReleaseNotes, - Mode::ProductAnnouncement, - ] { - assert!(!mode.wants_ascii_input(), "{mode:?} should keep the IME"); - } - } - - #[test] - fn sync_prefix_input_source_keeps_realm_across_multi_level_prefix_commands() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - - // Terminal -> Prefix switches once. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Prefix -> sub-mode and sub-mode -> sub-mode stay in the realm: no emit. - app.state.mode = Mode::Navigator; - app.sync_prefix_input_source(Mode::Prefix); - app.state.mode = Mode::Resize; - app.sync_prefix_input_source(Mode::Navigator); - assert!( - drained_prefix_active(&mut app).is_empty(), - "must not switch or restore while still in the realm" - ); - - // Leaving the realm back to the terminal restores. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Resize); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn sync_prefix_input_source_restores_when_entering_rename_text_mode() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Prefix -> RenameTab leaves the realm (text entry wants the IME): restore. - app.state.mode = Mode::RenameTab; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn handle_internal_event_prefix_input_source_applies_switch_and_restore() { - // The monolithic (in-process) path applies the host switch when it consumes the event. - let mut app = test_app(); - let fake = FakePrefixInputSource::switching(); - let switch_calls = fake.switch_calls.clone(); - let restore_calls = fake.restore_calls.clone(); - app.set_prefix_input_source(Box::new(fake)); - - app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true }); - assert_eq!(switch_calls.get(), 1); - assert_eq!(restore_calls.get(), 0); - - app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false }); - assert_eq!(restore_calls.get(), 1); - } - - #[test] - fn handle_internal_event_prefix_input_source_restore_is_safe_when_switch_was_noop() { - // Already-ASCII / failed-switch case: the restore on leave must stay harmless. - let mut app = test_app(); - let fake = FakePrefixInputSource::no_op(); - let switch_calls = fake.switch_calls.clone(); - let restore_calls = fake.restore_calls.clone(); - app.set_prefix_input_source(Box::new(fake)); - - app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true }); - app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false }); - assert_eq!(switch_calls.get(), 1); - assert_eq!(restore_calls.get(), 0); - } - - #[tokio::test] - async fn raw_input_dispatch_emits_input_source_intent_when_leaving_prefix() { - // Leaving prefix mode happens inside the raw-input dispatch, not in `handle_key` itself — - // the sync must sit at the dispatch layer so any event that exits prefix (here Esc) still - // emits the restore intent. - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - // ctrl+b (the default prefix key) enters prefix mode → switch intent. - app.handle_raw_input_event(raw_key( - KeyCode::Char('b'), - KeyModifiers::CONTROL, - KeyEventKind::Press, - )) - .await; - assert_eq!(app.state.mode, Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Esc leaves prefix mode → restore intent. - app.handle_raw_input_event(raw_key( - KeyCode::Esc, - KeyModifiers::empty(), - KeyEventKind::Press, - )) - .await; - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - fn config_env_lock() -> &'static Mutex<()> { crate::config::test_config_env_lock() } @@ -2393,7 +1032,7 @@ mod tests { let mut app = test_app(); app.git_refresh_in_flight = true; - let changed = app.handle_internal_event_with_prefix_sync(AppEvent::GitStatusRefreshed { + let changed = app.handle_internal_event_with_render_impact(AppEvent::GitStatusRefreshed { results: Vec::new(), cache_updates: Vec::new(), }); @@ -2424,10 +1063,10 @@ mod tests { result: Ok(output.map(str::to_string)), }; - assert!(!app.handle_internal_event_with_prefix_sync(event(generation, None))); - assert!(app.handle_internal_event_with_prefix_sync(event(generation, Some("ready")))); - assert!(!app.handle_internal_event_with_prefix_sync(event(generation, Some("ready")))); - assert!(!app.handle_internal_event_with_prefix_sync(event( + assert!(!app.handle_internal_event_with_render_impact(event(generation, None))); + assert!(app.handle_internal_event_with_render_impact(event(generation, Some("ready")))); + assert!(!app.handle_internal_event_with_render_impact(event(generation, Some("ready")))); + assert!(!app.handle_internal_event_with_render_impact(event( generation.wrapping_add(1), Some("stale"), ))); @@ -2475,59 +1114,6 @@ mod tests { } #[test] - fn clipboard_write_event_shows_feedback_toast() { - let mut app = test_app(); - - app.handle_internal_event(AppEvent::ClipboardWrite { - content: b"copied".to_vec(), - }); - - assert!(app.state.toast.is_none()); - let feedback = app.state.copy_feedback.as_ref().expect("copy feedback"); - assert_eq!(feedback.message, "copied to clipboard"); - assert!(app.copy_feedback_deadline.is_some()); - } - - #[test] - fn clipboard_feedback_can_be_disabled() { - let mut app = test_app(); - app.state.toast_config.clipboard.enabled = false; - - app.handle_internal_event(AppEvent::ClipboardWrite { - content: b"copied".to_vec(), - }); - - assert!(app.state.copy_feedback.is_none()); - assert!(app.copy_feedback_deadline.is_none()); - } - - #[test] - fn clipboard_feedback_does_not_replace_notification_toast() { - let mut app = test_app(); - app.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "pi needs attention".to_string(), - context: "background · 2".to_string(), - position: None, - target: None, - }); - let original_toast = app.state.toast.clone(); - - app.handle_internal_event(AppEvent::ClipboardWrite { - content: b"copied".to_vec(), - }); - - assert_eq!(app.state.toast, original_toast); - assert_eq!( - app.state - .copy_feedback - .as_ref() - .map(|feedback| feedback.message.as_str()), - Some("copied to clipboard") - ); - } - - #[test] fn notification_show_api_creates_herdr_toast_with_position() { let mut app = test_app(); app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; @@ -2564,38 +1150,6 @@ mod tests { } #[test] - fn notification_show_api_herdr_toast_expires() { - let mut app = test_app(); - app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - - let response = - app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { - id: "notify".into(), - method: crate::api::schema::Method::NotificationShow( - crate::api::schema::NotificationShowParams { - title: "build failed".into(), - body: None, - position: None, - sound: crate::api::schema::NotificationShowSound::None, - }, - ), - }); - - let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - crate::api::schema::ResponseResult::NotificationShow { - shown: true, - reason: crate::api::schema::NotificationShowReason::Shown, - } - ); - let deadline = app.toast_deadline.expect("api toast deadline"); - assert!(app.handle_scheduled_tasks(deadline, false)); - assert!(app.state.toast.is_none()); - assert!(app.toast_deadline.is_none()); - } - - #[test] fn notification_show_api_respects_off_delivery() { let mut app = test_app(); app.state.toast_config.delivery = crate::config::ToastDelivery::Off; @@ -2765,62 +1319,18 @@ mod tests { config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); } #[test] - fn startup_uses_configured_sidebar_state() { - let mut config = Config::default(); - config.ui.sidebar_start_collapsed = true; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert!(app.state.sidebar_collapsed); - } - - #[test] - fn startup_uses_redraw_on_focus_gained_config() { - let mut config = Config::default(); - config.ui.redraw_on_focus_gained = false; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert!(!app.state.redraw_on_focus_gained); - } - - #[test] - fn workspace_name_prompt_suppresses_default_creation_while_pending() { - let mut app = test_app(); - app.state.prompt_new_workspace_name = true; - - app.begin_tui_workspace_create("test.workspace.create"); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert!(app.state.pending_workspace_create_cwd.is_some()); - assert!(!app.ensure_default_workspace()); - assert!(app.state.workspaces.is_empty()); - - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); - assert!(app.state.workspaces.is_empty()); - assert!(app.state.pending_workspace_create_cwd.is_none()); - } - - #[test] - fn startup_uses_workspace_name_prompt_config() { - let mut config = Config::default(); - config.ui.prompt_new_workspace_name = true; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert!(app.state.prompt_new_workspace_name); - } - - #[test] fn theme_auto_switch_is_opt_in_and_preserves_manual_default() { let mut config = Config::default(); config.theme.name = Some("tokyo-night".to_string()); @@ -2837,7 +1347,13 @@ mod tests { }); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert!(!app.state.theme_runtime.auto_switch); assert_eq!(app.state.theme_name, "tokyo-night"); @@ -2850,12 +1366,19 @@ mod tests { config.theme.name = Some("tokyo-night".to_string()); config.theme.auto_switch = true; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!(app.state.theme_name, "tokyo-night"); - assert!( - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true) - ); + assert!(app.set_host_terminal_appearance_state( + Some(crate::terminal_theme::HostAppearance::Light), + true, + )); assert_eq!(app.state.theme_name, "tokyo-night-day"); assert_eq!(app.state.palette, state::Palette::tokyo_night_day()); @@ -2871,9 +1394,18 @@ mod tests { ..Default::default() }); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true); + app.set_host_terminal_appearance_state( + Some(crate::terminal_theme::HostAppearance::Light), + true, + ); assert_eq!(app.state.theme_name, "gruvbox-light"); assert_eq!( @@ -2903,7 +1435,13 @@ mod tests { ..Default::default() }); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!( app.state.palette.accent, @@ -2922,7 +1460,10 @@ mod tests { ratatui::style::Color::Rgb(16, 17, 18) ); - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true); + app.set_host_terminal_appearance_state( + Some(crate::terminal_theme::HostAppearance::Light), + true, + ); assert_eq!( app.state.palette.accent, @@ -2932,31 +1473,6 @@ mod tests { } #[test] - fn inferred_background_appearance_does_not_override_explicit_report() { - let mut config = Config::default(); - config.theme.name = Some("catppuccin".to_string()); - config.theme.auto_switch = true; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Dark, true); - app.update_host_terminal_theme( - crate::terminal_theme::DefaultColorKind::Background, - crate::terminal_theme::RgbColor { - r: 0xff, - g: 0xff, - b: 0xff, - }, - ); - - assert_eq!( - app.state.host_terminal_appearance, - Some(crate::terminal_theme::HostAppearance::Dark) - ); - assert_eq!(app.state.theme_name, "catppuccin"); - } - - #[test] fn startup_restores_preview_update_available_from_saved_notes() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("startup-preview-update-available"); @@ -2969,6 +1485,81 @@ mod tests { assert_eq!(app.state.update_available.as_deref(), Some("99.99.99")); assert!(app.state.latest_release_notes_available); + assert_eq!( + app.state + .latest_release_notes + .as_ref() + .map(|notes| notes.version.as_str()), + Some("99.99.99") + ); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn update_ready_refreshes_cached_release_notes() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("update-ready-refreshes-release-notes"); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + let mut app = test_app(); + assert!(app.state.latest_release_notes.is_none()); + + crate::release_notes::save_pending("99.99.99", "### Changed\n- One").unwrap(); + app.handle_internal_event(AppEvent::UpdateReady { + version: "99.99.99".into(), + install_command: "herdr update".into(), + }); + + assert_eq!( + app.state.latest_release_notes.as_ref().map(|notes| ( + notes.version.as_str(), + notes.body.as_str(), + notes.preview + )), + Some(("99.99.99", "### Changed\n- One", true)) + ); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn release_notes_dismiss_api_marks_current_seen_but_keeps_preview_unseen() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("release-notes-dismiss-persistence"); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let dismiss = |app: &mut App, version: &str| { + let response = app.handle_api_request(crate::api::schema::Request { + id: format!("dismiss-{version}"), + method: crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: version.to_owned(), + }, + ), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["result"]["type"], "ok"); + }; + let show_on_startup = || { + let stored: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(crate::release_notes::pending_path()).unwrap(), + ) + .unwrap(); + stored["show_on_startup"].as_bool() + }; + + let current = env!("CARGO_PKG_VERSION"); + crate::release_notes::save_pending(current, "### Changed\n- Current").unwrap(); + let mut app = test_app(); + dismiss(&mut app, current); + assert_eq!(show_on_startup(), Some(false)); + + crate::release_notes::save_pending("99.99.99", "### Changed\n- Preview").unwrap(); + let mut app = test_app(); + dismiss(&mut app, "99.99.99"); + assert_eq!(show_on_startup(), Some(true)); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); @@ -3005,10 +1596,15 @@ mod tests { }; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.state.release_notes.is_none()); assert!(app.state.latest_release_notes_available); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -3016,7 +1612,7 @@ mod tests { } #[test] - fn startup_still_auto_opens_unseen_product_announcement() { + fn startup_loads_unseen_product_announcement_for_clients() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("startup-product-announcement-auto-open"); let state_home = path.parent().unwrap().join("state"); @@ -3042,9 +1638,15 @@ mod tests { }; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); - assert_eq!(app.state.mode, Mode::ProductAnnouncement); + assert_eq!(app.state.mode, Mode::Navigate); assert_eq!( app.state .product_announcement @@ -3052,7 +1654,6 @@ mod tests { .map(|announcement| announcement.id.as_str()), Some("startup-announcement") ); - assert!(app.state.release_notes.is_none()); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); restore_xdg_state_home(original_xdg_state_home); @@ -3066,35 +1667,12 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[server]\nheadless_cols = 160\nheadless_rows = 50\n[ui]\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\nprompt_new_workspace_name = true\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n", + "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[server]\nheadless_cols = 160\nheadless_rows = 50\n[ui]\nagent_panel_sort = \"priority\"\n[ui.toast]\ndelivery = \"herdr\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let selection_pane = crate::layout::PaneId::alloc(); - app.state.selection = Some(crate::selection::Selection::range( - selection_pane, - 0, - 0, - 1, - None, - )); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 1, - last_mouse_screen_row: 1, - inner_rect: ratatui::layout::Rect::new(0, 0, 2, 2), - }); - let selection_deadline = Instant::now(); - app.selection_autoscroll_deadline = Some(selection_deadline); - app.selection_highlight_clear_deadline = Some(selection_deadline); - app.last_pane_click = Some(PaneClickState { - pane_id: selection_pane, - viewport_row: 0, - col: 0, - at: selection_deadline, - }); app.next_auto_update_check = Some(Instant::now()); app.next_agent_manifest_update_check = Some(Instant::now()); let report = app.reload_config(); @@ -3113,33 +1691,8 @@ mod tests { crate::config::ToastDelivery::Herdr ); assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); - assert!(!app.state.redraw_on_focus_gained); - assert!(!app.state.copy_on_select); - assert!(app.state.prompt_new_workspace_name); - assert!(app.state.selection.is_some()); - assert!(app.state.selection_autoscroll.is_some()); - assert_eq!(app.selection_autoscroll_deadline, Some(selection_deadline)); - assert_eq!( - app.selection_highlight_clear_deadline, - Some(selection_deadline) - ); - assert!(app.last_pane_click.is_some()); - - app.state.mode = Mode::Copy; - app.state.selection = Some(crate::selection::Selection::range( - selection_pane, - 0, - 0, - 1, - None, - )); let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert!(app.state.selection.is_some()); - assert_eq!( - app.state.right_click_passthrough_modifiers, - Some(KeyModifiers::CONTROL) - ); assert!(app.state.request_client_config_reload); assert_eq!(app.state.default_shell, "nu"); assert_eq!( @@ -3154,7 +1707,6 @@ mod tests { assert!(!app.update_manifest_check_enabled); assert!(app.next_auto_update_check.is_none()); assert!(app.next_agent_manifest_update_check.is_none()); - assert!(app.state.switch_ascii_input_source_in_prefix); assert!(app.state.config_diagnostic.is_none()); let toast = app.state.toast.as_ref().unwrap(); assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled); @@ -3166,23 +1718,19 @@ mod tests { } #[test] - fn reload_config_requests_client_reload_for_host_cursor_only_change() { + fn reload_config_requests_client_reload_for_key_only_change() { let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-host-cursor"); + let path = temp_config_path("reload-config-key-only"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "[ui]\nhost_cursor = \"native\"\n").unwrap(); + std::fs::write(&path, "[keys]\nprefix = \"ctrl+a\"\n").unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); app.state.request_client_config_reload = false; - let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!( - app.loaded_host_cursor, - crate::config::HostCursorModeConfig::Native - ); + assert_eq!(app.state.prefix_code, KeyCode::Char('a')); assert!(app.state.request_client_config_reload); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -3190,31 +1738,24 @@ mod tests { } #[test] - fn reload_config_updates_sidebar_width_only_when_config_owned() { + fn reload_config_requests_client_reload_for_host_cursor_only_change() { let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-width"); + let path = temp_config_path("reload-config-host-cursor"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "[ui]\nhost_cursor = \"native\"\n").unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - assert_eq!( - app.state.sidebar_width_source, - state::SidebarWidthSource::ConfigDefault - ); + app.state.request_client_config_reload = false; - std::fs::write(&path, "[ui]\nsidebar_width = 34\n").unwrap(); let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.default_sidebar_width, 34); - assert_eq!(app.state.sidebar_width, 34); - app.state.sidebar_width = 31; - app.state.sidebar_width_source = state::SidebarWidthSource::Manual; - std::fs::write(&path, "[ui]\nsidebar_width = 35\n").unwrap(); - let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.default_sidebar_width, 35); - assert_eq!(app.state.sidebar_width, 31); + assert_eq!( + app.loaded_host_cursor, + crate::config::HostCursorModeConfig::Native + ); + assert!(app.state.request_client_config_reload); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); @@ -3233,11 +1774,9 @@ mod tests { "[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\nrow_gap = 1\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\nrow_gap = 3\n", ) .unwrap(); - app.state.agent_panel_scroll = 5; let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.agent_panel_scroll, 0); assert_eq!( app.state.sidebar_agents.rows, vec![vec![ @@ -3276,146 +1815,6 @@ mod tests { } #[test] - fn reload_config_does_not_reset_sidebar_to_startup_state() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-start-collapsed"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert!(!app.state.sidebar_collapsed); - - std::fs::write(&path, "[ui]\nsidebar_start_collapsed = true\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert!(!app.state.sidebar_collapsed); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn reload_config_updates_sidebar_collapsed_mode() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-collapsed-mode"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.sidebar_collapsed_mode, - crate::config::SidebarCollapsedModeConfig::Compact - ); - - std::fs::write(&path, "[ui]\nsidebar_collapsed_mode = \"hidden\"\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!( - app.state.sidebar_collapsed_mode, - crate::config::SidebarCollapsedModeConfig::Hidden - ); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn reload_config_updates_sidebar_bounds_and_reclamps() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-bounds"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - // Default bounds. - assert_eq!(app.state.sidebar_min_width, 18); - assert_eq!(app.state.sidebar_max_width, 36); - assert_eq!( - app.state.mobile_width_threshold, - crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD - ); - - // Manually set a width and flip the source so the existing - // sidebar_width-only-when-config-owned guard does NOT update it. - app.state.sidebar_width = 30; - app.state.sidebar_width_source = state::SidebarWidthSource::Manual; - - // Tightening max below the current width must re-clamp the live width - // even when source is Manual — bounds always apply. - std::fs::write(&path, "[ui]\nsidebar_max_width = 24\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.sidebar_max_width, 24); - assert_eq!( - app.state.sidebar_width, 24, - "manual width must re-clamp to new max" - ); - - // Loosening max leaves the live width alone (it's already within bounds). - app.state.sidebar_width = 24; - std::fs::write(&path, "[ui]\nsidebar_max_width = 60\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.sidebar_max_width, 60); - assert_eq!(app.state.sidebar_width, 24); - - // Raising min above the current width re-clamps upward. - std::fs::write(&path, "[ui]\nsidebar_min_width = 30\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.sidebar_min_width, 30); - assert_eq!( - app.state.sidebar_width, 30, - "manual width must re-clamp up to new min" - ); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn reload_config_updates_mobile_width_threshold() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-mobile-width-threshold"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.mobile_width_threshold, - crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD - ); - - std::fs::write(&path, "[ui]\nmobile_width_threshold = 96\n").unwrap(); - let report = app.reload_config(); - - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.mobile_width_threshold, 96); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn app_new_falls_back_to_default_bounds_on_inverted_config() { - let mut config = Config::default(); - config.ui.sidebar_min_width = 50; - config.ui.sidebar_max_width = 30; - - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert_eq!( - app.state.sidebar_min_width, 18, - "App::new must fall back to default min when bounds are inverted" - ); - assert_eq!( - app.state.sidebar_max_width, 36, - "App::new must fall back to default max when bounds are inverted" - ); - } - - #[test] fn reload_config_invalid_sidebar_bounds_keeps_previous_ui_and_returns_partial() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("reload-config-invalid-sidebar-bounds"); @@ -3423,17 +1822,15 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let original_min = app.state.sidebar_min_width; - let original_max = app.state.sidebar_max_width; - let original_mouse_capture = app.state.mouse_capture; + let original_pane_borders = app.state.pane_borders; // Pair the bad bounds with another `[ui]` field change to confirm the // entire section is treated as invalid (not just the bounds). - let target_mouse_capture = !original_mouse_capture; + let target_pane_borders = !original_pane_borders; std::fs::write( &path, format!( - "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\nmouse_capture = {}\n", - target_mouse_capture + "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\npane_borders = {}\n", + target_pane_borders ), ) .unwrap(); @@ -3445,11 +1842,9 @@ mod tests { && diagnostic.contains("sidebar_max_width") && diagnostic.contains("greater") })); - assert_eq!(app.state.sidebar_min_width, original_min); - assert_eq!(app.state.sidebar_max_width, original_max); assert_eq!( - app.state.mouse_capture, original_mouse_capture, - "[ui] is treated as invalid on bad bounds; mouse_capture must not apply" + app.state.pane_borders, original_pane_borders, + "[ui] is treated as invalid on bad bounds; pane_borders must not apply" ); assert_eq!( app.state.config_diagnostic.as_deref(), @@ -3501,10 +1896,10 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let target_mouse_capture = !app.state.mouse_capture; + let target_pane_borders = !app.state.pane_borders; std::fs::write( &path, - format!("[ui]\nmouse_capture = {target_mouse_capture}\nmouse_captur = false\n"), + format!("[ui]\npane_borders = {target_pane_borders}\nmouse_captur = false\n"), ) .unwrap(); @@ -3515,7 +1910,7 @@ mod tests { report.diagnostics, vec!["unknown config key ui.mouse_captur; ignoring key"] ); - assert_eq!(app.state.mouse_capture, target_mouse_capture); + assert_eq!(app.state.pane_borders, target_pane_borders); assert_eq!( app.state.config_diagnostic.as_deref(), Some("config.toml has unknown keys; herdr config check") @@ -3622,85 +2017,6 @@ mod tests { std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - - #[test] - fn settings_save_toast_delivery_persists_then_applies_live_config() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("settings-save-toast-delivery"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "onboarding = false\n").unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.toast_config.delivery, - crate::config::ToastDelivery::Off - ); - - app.save_toast_delivery(crate::config::ToastDelivery::Terminal); - - assert_eq!( - app.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal - ); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("delivery = \"terminal\"")); - assert!(app.state.config_diagnostic.is_none()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn save_status_indicators_persists_then_applies_live_config() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("save-status-indicators"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "onboarding = false\n").unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.status_indicators, - crate::config::StatusIndicatorStyle::Dots - ); - - app.save_status_indicators(crate::config::StatusIndicatorStyle::Symbols); - - assert_eq!( - app.state.status_indicators, - crate::config::StatusIndicatorStyle::Symbols - ); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("status_indicators = \"symbols\"")); - assert!(app.state.config_diagnostic.is_none()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn save_agent_panel_sort_persists_then_applies_live_config() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("save-agent-panel-sort"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "onboarding = false\n").unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Spaces); - - app.save_agent_panel_sort(state::AgentPanelSort::Priority); - - assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("agent_panel_sort = \"priority\"")); - assert!(app.state.config_diagnostic.is_none()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - #[test] fn reload_config_keeps_current_state_on_invalid_toml() { let _guard = config_env_lock().lock().unwrap(); @@ -3734,323 +2050,6 @@ mod tests { std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - - #[tokio::test] - async fn raw_input_waits_when_reader_is_gone() { - let result = - tokio::time::timeout(Duration::from_millis(20), recv_raw_input_or_pending(None)).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn terminal_mode_handles_repeat_key_events() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let handled = app - .handle_raw_input_event(raw_key( - KeyCode::Backspace, - KeyModifiers::empty(), - KeyEventKind::Repeat, - )) - .await; - - assert!(handled); - } - - #[tokio::test] - async fn monolithic_input_forwards_report_all_printable_event_kinds() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - for kind in [ - KeyEventKind::Press, - KeyEventKind::Repeat, - KeyEventKind::Release, - ] { - app.handle_raw_input_event(raw_key(KeyCode::Char('j'), KeyModifiers::empty(), kind)) - .await; - } - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:2u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn outer_focus_gained_marks_visible_done_panes_seen() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let root_pane = workspace.tabs[0].root_pane; - let split_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - let background_tab = workspace.test_add_tab(Some("background")); - let background_pane = workspace.tabs[background_tab].root_pane; - - app.state.workspaces = vec![workspace]; - app.state.ensure_test_terminals(); - let root_terminal_id = app.state.workspaces[0].tabs[0].panes[&root_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&root_terminal_id) - .unwrap() - .state = AgentState::Idle; - app.state.workspaces[0].tabs[0] - .panes - .get_mut(&root_pane) - .unwrap() - .seen = false; - let split_terminal_id = app.state.workspaces[0].tabs[0].panes[&split_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&split_terminal_id) - .unwrap() - .state = AgentState::Idle; - app.state.workspaces[0].tabs[0] - .panes - .get_mut(&split_pane) - .unwrap() - .seen = false; - let bg_terminal_id = app.state.workspaces[0].tabs[background_tab].panes[&background_pane] - .attached_terminal_id - .clone(); - app.state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Idle; - app.state.workspaces[0].tabs[background_tab] - .panes - .get_mut(&background_pane) - .unwrap() - .seen = false; - - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.outer_terminal_focus = Some(false); - - let handled = app - .handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) - .await; - - assert!(handled); - assert_eq!(app.state.outer_terminal_focus, Some(true)); - assert!(app.state.workspaces[0].tabs[0].panes[&root_pane].seen); - assert!(app.state.workspaces[0].tabs[0].panes[&split_pane].seen); - assert!(!app.state.workspaces[0].tabs[background_tab].panes[&background_pane].seen); - } - - #[tokio::test] - async fn outer_focus_gained_does_not_require_full_redraw_when_disabled() { - let mut app = test_app(); - app.state.redraw_on_focus_gained = false; - - let handled = app - .handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) - .await; - - assert!(handled); - assert_eq!(app.state.outer_terminal_focus, Some(true)); - assert!(!app.full_redraw_pending); - } - - #[tokio::test] - async fn monolithic_outer_focus_events_reach_reporting_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("focus-reporting"); - let pane_id = workspace.tabs[0].root_pane; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[?1004h", - 4, - ); - workspace.insert_test_runtime(pane_id, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - assert!( - app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) - .await - ); - assert_eq!( - input_rx - .recv() - .await - .expect("forwarded focus gained report"), - bytes::Bytes::from_static(b"\x1b[I") - ); - - assert!( - !app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusLost) - .await - ); - assert_eq!( - input_rx.recv().await.expect("forwarded focus lost report"), - bytes::Bytes::from_static(b"\x1b[O") - ); - } - - #[tokio::test] - async fn outer_focus_events_reconcile_pending_pane_focus() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("focus-transition"); - let previous_pane = workspace.tabs[0].root_pane; - let next_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(previous_pane); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[?1004h", - 4, - ); - workspace.insert_test_runtime(next_pane, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.last_focus = Some((0, previous_pane)); - - assert!(app.state.focus_pane_in_workspace(0, next_pane)); - assert!( - !app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusLost) - .await - ); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[O") - ); - assert!(input_rx.try_recv().is_err()); - - assert!(app.state.focus_pane_in_workspace(0, previous_pane)); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[O") - ); - - assert!(app.state.focus_pane_in_workspace(0, next_pane)); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[O") - ); - - assert!( - app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) - .await - ); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[I") - ); - - assert!(app.state.focus_pane_in_workspace(0, previous_pane)); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[O") - ); - assert!(app.state.focus_pane_in_workspace(0, next_pane)); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[I") - ); - } - - #[tokio::test] - async fn repeat_key_events_are_ignored_outside_terminal_mode() { - let mut app = test_app(); - app.state.mode = Mode::ReleaseNotes; - app.state.release_notes = Some(release_notes_state()); - - let handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Repeat, - )) - .await; - - assert!(!handled); - assert_eq!(app.state.mode, Mode::ReleaseNotes); - assert!(app.state.release_notes.is_some()); - } - - #[tokio::test] - async fn modal_press_does_not_leak_repeat_into_terminal_mode() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::ReleaseNotes; - app.state.release_notes = Some(release_notes_state()); - - let press_handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Press, - )) - .await; - let repeat_handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Repeat, - )) - .await; - let release_handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Release, - )) - .await; - let next_press_handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Press, - )) - .await; - - assert!(press_handled); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(!repeat_handled); - assert!(!release_handled); - assert!(next_press_handled); - } - #[test] fn read_only_api_requests_do_not_force_rerender() { let read_only = crate::api::schema::Request { @@ -4117,6 +2116,35 @@ mod tests { crate::api::schema::AgentViewClearParams::default(), ), }; + let command_invoke = crate::api::schema::Request { + id: "req_10".into(), + method: crate::api::schema::Method::CommandInvoke( + crate::api::schema::CommandInvokeParams { + command_id: "cmd_boot_1_0".into(), + workspace_id: Some("w1".into()), + tab_id: Some("w1:t1".into()), + pane_id: Some("w1:p1".into()), + selection: None, + }, + ), + }; + let announcement_dismiss = crate::api::schema::Request { + id: "req_11".into(), + method: crate::api::schema::Method::ProductAnnouncementDismiss( + crate::api::schema::ProductAnnouncementDismissParams { + version: "0.8.2".into(), + id: "client-shell".into(), + }, + ), + }; + let release_notes_dismiss = crate::api::schema::Request { + id: "req_12".into(), + method: crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: "0.8.2".into(), + }, + ), + }; assert!(!crate::api::request_changes_ui(&read_only)); assert!(!crate::api::request_changes_ui(&worktree_list)); @@ -4127,6 +2155,9 @@ mod tests { assert!(crate::api::request_changes_ui(&pane_focus_direction)); assert!(crate::api::request_changes_ui(&pane_resize)); assert!(crate::api::request_changes_ui(&agent_view)); + assert!(crate::api::request_changes_ui(&command_invoke)); + assert!(crate::api::request_changes_ui(&announcement_dismiss)); + assert!(crate::api::request_changes_ui(&release_notes_dismiss)); } #[test] @@ -4538,105 +2569,6 @@ mod tests { } #[tokio::test] - async fn pane_split_request_targets_pane_in_background_tab() { - let _guard = config_env_lock().lock().unwrap(); - let original_shell = std::env::var_os("SHELL"); - std::env::set_var("SHELL", exiting_test_command()); - - let mut app = test_app(); - let mut workspace = Workspace::test_new("api-pane-split-background-tab"); - let active_pane = workspace.tabs[0].root_pane; - let background_tab = workspace.test_add_tab(Some("worker")); - let target_pane = workspace.tabs[background_tab].root_pane; - workspace.switch_tab(background_tab); - let background_previous_focus = - workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.switch_tab(0); - app.state.workspaces = vec![workspace]; - app.state.ensure_test_terminals(); - let split_cwd = std::env::temp_dir(); - let target_terminal_id = app.state.workspaces[0] - .pane_state(target_pane) - .unwrap() - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&target_terminal_id) - .unwrap() - .cwd = split_cwd.clone(); - app.state.active = Some(0); - app.state.selected = 0; - app.state - .focus_pane_in_workspace(0, background_previous_focus); - app.state.focus_pane_in_workspace(0, active_pane); - - let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id; - let target_tab_id = app.public_tab_id(0, background_tab).unwrap(); - - let response = app.handle_api_request(crate::api::schema::Request { - id: "req_pane_split_background_tab".into(), - method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams { - workspace_id: None, - target_pane_id: Some(target_pane_id), - direction: crate::api::schema::SplitDirection::Right, - ratio: None, - cwd: None, - focus: false, - right_click: Default::default(), - env: Default::default(), - }), - }); - let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - - assert_eq!(response["result"]["type"], "pane_info"); - assert_eq!(response["result"]["pane"]["tab_id"], target_tab_id); - let response_cwd = - std::path::PathBuf::from(response["result"]["pane"]["cwd"].as_str().unwrap()); - assert_eq!( - crate::worktree::canonical_or_original(&response_cwd), - crate::worktree::canonical_or_original(&split_cwd) - ); - assert_eq!(response["result"]["pane"]["focused"], false); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].active_tab, 0); - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - active_pane - ); - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert_eq!( - app.state.workspaces[0].tabs[background_tab] - .layout - .focused(), - background_previous_focus - ); - assert_eq!( - app.state.workspaces[0].tabs[background_tab] - .layout - .pane_count(), - 3 - ); - app.state.last_pane(); - assert_eq!(app.state.workspaces[0].active_tab, background_tab); - assert_eq!( - app.state.workspaces[0].tabs[background_tab] - .layout - .focused(), - background_previous_focus - ); - - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } - match original_shell { - Some(value) => std::env::set_var("SHELL", value), - None => std::env::remove_var("SHELL"), - } - } - - #[tokio::test] async fn pane_split_request_focuses_new_pane_when_requested() { let _guard = config_env_lock().lock().unwrap(); let original_shell = std::env::var_os("SHELL"); @@ -4977,15 +2909,14 @@ mod tests { let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["error"]["code"], "confirmation_required"); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.selected, 0); + assert_eq!(app.state.selected, 1); assert_eq!(app.state.workspaces.len(), 2); } #[test] fn session_dirty_flag_schedules_debounced_save() { let mut app = test_app(); - app.no_session = false; + app.policy.persist_session = true; app.state.session_dirty = true; app.sync_session_save_schedule(); @@ -4995,24 +2926,9 @@ mod tests { } #[test] - fn next_loop_deadline_includes_session_save_deadline() { - let mut app = test_app(); - let now = Instant::now(); - app.session_save_deadline = Some(now + Duration::from_secs(2)); - app.next_resize_poll = now + Duration::from_secs(5); - app.next_auto_update_check = Some(now + Duration::from_secs(6)); - - assert_eq!( - app.next_loop_deadline(now, false), - app.session_save_deadline - ); - } - - #[test] fn headless_next_loop_deadline_ignores_resize_poll() { let mut app = test_app(); let now = Instant::now(); - app.next_resize_poll = now + Duration::from_millis(100); app.session_save_deadline = Some(now + Duration::from_secs(2)); app.next_auto_update_check = Some(now + Duration::from_secs(6)); @@ -5026,7 +2942,6 @@ mod tests { fn headless_next_loop_deadline_returns_none_when_resize_poll_is_only_deadline() { let mut app = test_app(); let now = Instant::now(); - app.next_resize_poll = now - Duration::from_millis(1); app.config_diagnostic_deadline = None; app.toast_deadline = None; app.next_auto_update_check = None; @@ -5040,16 +2955,6 @@ mod tests { } #[test] - fn due_session_save_deadline_is_cleared() { - let mut app = test_app(); - app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1)); - - app.handle_scheduled_tasks(Instant::now(), false); - - assert!(app.session_save_deadline.is_none()); - } - - #[test] fn due_session_save_starts_background_writer() { let _guard = crate::config::test_config_env_lock().lock().unwrap(); let config_home = unique_temp_path("background-session-save"); @@ -5057,12 +2962,12 @@ mod tests { std::env::remove_var(crate::session::SESSION_ENV_VAR); let mut app = test_app(); - app.no_session = false; + app.policy.persist_session = true; app.state.workspaces = vec![Workspace::test_new("autosave")]; app.state.ensure_test_terminals(); app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1)); - app.handle_scheduled_tasks(Instant::now(), false); + app.start_background_session_save(); assert!(app.session_save_thread.is_some()); assert!(app.session_save_deadline.is_none()); @@ -5076,7 +2981,7 @@ mod tests { #[test] fn background_session_save_reschedules_when_writer_is_busy() { let mut app = test_app(); - app.no_session = false; + app.policy.persist_session = true; let (release_tx, release_rx) = std::sync::mpsc::channel(); app.session_save_thread = Some(std::thread::spawn(move || { let _ = release_rx.recv(); @@ -5088,14 +2993,14 @@ mod tests { assert!(app.session_save_deadline.is_some()); release_tx.send(()).unwrap(); - app.no_session = true; + app.policy.persist_session = false; app.save_session_now(); } #[test] fn final_session_save_joins_background_writer_before_returning() { let mut app = test_app(); - app.no_session = true; + app.policy.persist_session = false; let (release_tx, release_rx) = std::sync::mpsc::channel(); let (done_tx, done_rx) = std::sync::mpsc::channel(); app.session_save_thread = Some(std::thread::spawn(move || { @@ -5113,52 +3018,6 @@ mod tests { done_rx.try_recv().unwrap(); assert!(app.session_save_thread.is_none()); } - - #[test] - fn next_loop_deadline_includes_selection_autoscroll_deadline() { - let mut app = test_app(); - let now = Instant::now(); - app.next_resize_poll = now + Duration::from_millis(300); - app.selection_autoscroll_deadline = Some(now + Duration::from_millis(5)); - app.session_save_deadline = Some(now + Duration::from_millis(200)); - assert_eq!( - app.next_loop_deadline(now, false), - app.selection_autoscroll_deadline - ); - } - - #[test] - fn tick_selection_autoscroll_self_heals_when_state_cleared() { - let mut app = test_app(); - let now = Instant::now(); - app.state.selection_autoscroll = None; - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_on_rect_change() { - let mut app = test_app(); - let now = Instant::now(); - let ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - app.state.workspaces.push(ws); - app.state.active = Some(0); - app.state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); - // Set autoscroll with a stale inner_rect that doesn't match pane_infos - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 999, - inner_rect: ratatui::layout::Rect::new(0, 0, 1, 1), // wrong rect - }); - 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()); - } - #[tokio::test] async fn full_internal_event_queue_eventually_applies_working_to_idle_transition() { let mut app = test_app(); @@ -5239,1368 +3098,4 @@ mod tests { "Working→Idle should still apply after temporary queue pressure" ); } - - #[test] - fn route_client_input_dispatches_navigate_mode_keybinds() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - - // Start in navigate mode. - app.state.mode = Mode::Navigate; - - // Send Ctrl+B then Esc (prefix → leave navigate mode). - // Ctrl+B is 0x02 in raw terminal input. - // After entering navigate mode and pressing Esc, we should leave navigate mode. - let esc_bytes = vec![0x1b]; // Esc - app.route_client_input(esc_bytes); - // Esc in navigate mode should leave navigate mode. - assert_eq!( - app.state.mode, - Mode::Terminal, - "Esc should leave navigate mode and return to Terminal mode" - ); - } - - #[test] - fn explicit_text_commit_does_not_trigger_navigate_binding() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.detach_exits = false; - app.state.mode = Mode::Navigate; - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Text( - crate::input::TextCommit::new("q"), - )], - false, - ); - - assert!(!app.state.detach_requested); - assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.input_leases.is_empty()); - } - - #[test] - fn route_client_input_q_detaches_in_persistence_mode() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.detach_exits = false; - - // Start in navigate mode. - app.state.mode = Mode::Navigate; - assert!(!app.state.detach_requested); - - let q_bytes = b"q".to_vec(); - app.route_client_input(q_bytes); - - assert!( - app.state.detach_requested, - "q should detach in persistence mode" - ); - assert_eq!( - app.state.mode, - Mode::Terminal, - "q should leave navigate mode" - ); - } - - #[test] - fn route_client_input_prefix_then_q_detaches_in_persistence_mode() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.detach_exits = false; - - // Start in terminal mode (default after workspace creation). - app.state.mode = Mode::Terminal; - assert!(!app.state.detach_requested); - - // Send Ctrl+B (prefix key, raw byte 0x02). - let prefix_bytes = vec![0x02]; - app.route_client_input(prefix_bytes); - - assert_eq!( - app.state.mode, - Mode::Prefix, - "prefix key should enter prefix mode" - ); - assert!( - !app.state.detach_requested, - "prefix key should not set detach flag" - ); - - let q_bytes = b"q".to_vec(); - app.route_client_input(q_bytes); - - assert!( - app.state.detach_requested, - "q should detach in persistence mode" - ); - assert_eq!( - app.state.mode, - Mode::Terminal, - "q should leave navigate mode" - ); - } - - #[test] - fn route_client_input_prefix_tab_dispatches_global_last_pane() { - let config: Config = toml::from_str( - r#" -[keys] -last_pane = "prefix+tab" -"#, - ) - .unwrap(); - let mut app = test_app(); - let mut first = Workspace::test_new("one"); - let first_second_tab = first.test_add_tab(Some("logs")); - let first_second_root = first.tabs[first_second_tab].root_pane; - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - app.state.workspaces = vec![first, second]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.keybinds = config.keybinds(); - app.state.mode = Mode::Terminal; - app.state.switch_workspace_tab(0, first_second_tab); - app.state.switch_workspace_tab(1, 0); - - app.route_client_input(vec![0x02, b'\t']); - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].active_tab, first_second_tab); - assert_eq!( - app.state.workspaces[0].focused_pane_id(), - Some(first_second_root) - ); - - app.route_client_input(vec![0x02, b'\t']); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(second_root)); - } - - #[tokio::test] - async fn route_client_input_double_prefix_passes_prefix_through_to_focused_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prefix_code = KeyCode::Char('l'); - app.state.prefix_mods = KeyModifiers::CONTROL; - - app.route_client_input(vec![0x0c]); - assert_eq!(app.state.mode, Mode::Prefix); - - app.route_client_input(vec![0x0c]); - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from(vec![0x0c])); - } - - #[tokio::test] - async fn route_client_input_reencodes_terminal_keys_for_focused_pane_protocol() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - // Ghostty/kitty-style Ctrl-C should be normalized back to the pane's - // negotiated encoding instead of being forwarded verbatim. - app.route_client_input(b"\x1b[99;5u".to_vec()); - - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from(vec![3])); - - // iTerm2 and rxvt-style hosts may send F4 as CSI 14~. Normalize it - // through the same semantic key path instead of leaking host bytes. - app.route_client_input(b"\x1b[14~".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1bOS") - ); - } - - #[tokio::test] - async fn host_report_all_supplies_printable_releases_for_event_type_only_panes() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[>4;2m\x1b[=3;1u", - 4, - ); - assert_eq!( - runtime.keyboard_protocol(), - crate::input::KeyboardProtocol::Kitty { flags: 3 } - ); - assert!(runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - assert!(app.host_keyboard_report_all_requested()); - - app.route_client_input(b"\x1b[106;1:1u\x1b[106;1:2u\x1b[106;1:3u".to_vec()); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j")); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j")); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) - .unwrap(); - runtime.test_process_pty_bytes(b"\x1b[>4;0m"); - assert!(!runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)); - assert!(!app.host_keyboard_report_all_requested()); - - #[cfg(unix)] - { - runtime.test_process_pty_bytes(b"\x1b[>4;1m"); - assert!(!runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)); - assert!(!app.host_keyboard_report_all_requested()); - } - } - - #[tokio::test] - async fn host_report_all_follows_terminal_protocol_and_command_modes() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, _rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 1); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - assert!(app.host_keyboard_report_all_requested()); - - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) - .unwrap() - .test_process_pty_bytes(b"\x1b[<u"); - assert!(!app.host_keyboard_report_all_requested()); - - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) - .unwrap() - .test_process_pty_bytes(b"\x1b[>15u"); - let other_pane = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let (other_runtime, _rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>7u", 1); - app.state.workspaces[0].tabs[0] - .runtimes - .insert(other_pane, other_runtime); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(other_pane)); - assert!(!app.host_keyboard_report_all_requested()); - - assert!(app.state.focus_pane_in_workspace(0, focused)); - app.state.mode = Mode::Prefix; - assert!(app.host_keyboard_report_all_requested()); - app.state.mode = Mode::Navigate; - assert!(app.host_keyboard_report_all_requested()); - app.state.mode = Mode::RenameWorkspace; - assert!(!app.host_keyboard_report_all_requested()); - } - - #[tokio::test] - async fn route_client_input_forwards_report_all_printable_event_kinds() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106u\x1b[106;1:2u\x1b[106;1:3u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:2u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn explicit_text_commit_bypasses_bindings_leases_and_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Text( - crate::input::TextCommit::new("你🙂"), - )], - false, - ); - - assert_eq!(rx.recv().await.unwrap().as_ref(), "你🙂".as_bytes()); - assert!(rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn committed_ime_text_bypasses_report_all_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input("你".as_bytes().to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static("你".as_bytes()) - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn kitty_associated_ime_text_bypasses_report_all_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[32;;20320:22909u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static("你好".as_bytes()) - ); - assert!(rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn committed_ascii_uppercase_bypasses_report_all_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"A".to_vec()); - - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"A")); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn committed_text_does_not_erase_owned_physical_key() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106u".to_vec()); - app.route_client_input(b"j".to_vec()); - app.route_client_input(b"\x1b[106;1:3u".to_vec()); - - assert_eq!( - rx.try_recv().expect("physical press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("committed text"), - bytes::Bytes::from_static(b"j") - ); - assert_eq!( - rx.try_recv().expect("physical release"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn outer_focus_loss_releases_owned_report_all_keys() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events( - vec![ - raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - ), - crate::raw_input::RawInputEvent::OuterFocusLost, - ], - false, - ); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("synthetic release on focus loss"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn disconnected_input_source_releases_owned_report_all_keys() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events_from( - 42, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - )], - false, - ); - app.clear_input_source(42); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("synthetic release on disconnect"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn physical_count_one_repeats_and_release_keep_the_pressed_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let pressed_pane = workspace.focused_pane_id().unwrap(); - let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(pressed_pane); - let (pressed_runtime, mut pressed_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 5); - let (other_runtime, mut other_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0] - .runtimes - .insert(pressed_pane, pressed_runtime); - workspace.tabs[0].runtimes.insert(other_pane, other_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 65, - virtual_scan_code: 30, - unicode: 97, - control_key_state: 0, - }; - let roundtrip = |events: Vec<crate::protocol::ClientInputEvent>| { - let message = crate::protocol::ClientMessage::InputEvents { events }; - let encoded = - bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); - let (decoded, _): (crate::protocol::ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - let crate::protocol::ClientMessage::InputEvents { events } = decoded else { - panic!("expected structured input events"); - }; - events - .into_iter() - .map(|event| event.to_raw_input_event()) - .collect() - }; - let event = |kind, record| crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('a'), - modifiers: 0, - kind, - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::WindowsConsole { record }, - }; - - app.route_client_events( - roundtrip(vec![event(crate::protocol::ClientKeyKind::Press, record)]), - false, - ); - assert!(app.state.focus_pane_in_workspace(0, other_pane)); - app.route_client_events( - roundtrip(vec![ - event(crate::protocol::ClientKeyKind::Press, record), - event(crate::protocol::ClientKeyKind::Repeat, record), - event( - crate::protocol::ClientKeyKind::Release, - crate::input::WindowsKeyRecord { - key_down: false, - unicode: 0, - ..record - }, - ), - ]), - false, - ); - - for expected in [ - b"\x1b[97;1:1u".as_slice(), - b"\x1b[97;1:2u".as_slice(), - b"\x1b[97;1:2u".as_slice(), - b"\x1b[97;1:3u".as_slice(), - ] { - assert_eq!(pressed_rx.try_recv().unwrap().as_ref(), expected); - } - assert!(pressed_rx.try_recv().is_err()); - assert!(other_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn grouped_physical_press_and_runtime_loss_preserve_count_then_close_the_lease() { - let mut app = test_app(); - let workspace = Workspace::test_new("test"); - let pane_id = workspace.focused_pane_id().unwrap(); - let terminal_id = workspace.tabs[0].terminal_id(pane_id).unwrap().clone(); - app.state.workspaces = vec![workspace]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - app.terminal_runtimes.insert(terminal_id.clone(), runtime); - - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 3, - virtual_key_code: 65, - virtual_scan_code: 30, - unicode: 97, - control_key_state: 0, - }; - let message = crate::protocol::ClientMessage::InputEvents { - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('a'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 3, - generated_text: None, - source: crate::protocol::ClientKeySource::WindowsConsole { record }, - }], - }; - let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); - let (decoded, _): (crate::protocol::ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - let crate::protocol::ClientMessage::InputEvents { events } = decoded else { - panic!("expected structured input events"); - }; - - app.route_client_events( - events - .into_iter() - .map(|event| event.to_raw_input_event()) - .collect(), - false, - ); - app.shutdown_terminal_runtime(terminal_id.clone()); - - assert_eq!( - rx.try_recv().expect("grouped press"), - bytes::Bytes::from_static(b"\x1b[97;1:1u\x1b[97;1:2u\x1b[97;1:2u") - ); - assert_eq!( - rx.try_recv() - .expect("synthetic release before runtime shutdown"), - bytes::Bytes::from_static(b"\x1b[97;1:3u") - ); - assert!(rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - assert!(app.terminal_runtimes.get(&terminal_id).is_none()); - } - - #[tokio::test] - async fn report_all_repeat_and_release_return_to_the_pressed_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let pressed_pane = workspace.focused_pane_id().unwrap(); - let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(pressed_pane); - let (pressed_runtime, mut pressed_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - let (other_runtime, mut other_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0] - .runtimes - .insert(pressed_pane, pressed_runtime); - workspace.tabs[0].runtimes.insert(other_pane, other_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106u".to_vec()); - assert!(app.state.focus_pane_in_workspace(0, other_pane)); - app.route_client_input(b"\x1b[106;1:2u\x1b[106;1:3u".to_vec()); - - assert_eq!( - pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:2u") - ); - assert_eq!( - pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(pressed_rx.try_recv().is_err()); - assert!(other_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn report_all_key_ownership_is_isolated_by_client() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let first_pane = workspace.focused_pane_id().unwrap(); - let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(first_pane); - let (first_runtime, mut first_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - let (second_runtime, mut second_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(first_pane, first_runtime); - workspace.tabs[0] - .runtimes - .insert(second_pane, second_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events_from( - 1, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - )], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, second_pane)); - app.route_client_events_from( - 2, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - )], - false, - ); - app.route_client_events_from( - 1, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Release, - )], - false, - ); - app.route_client_events_from( - 2, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Release, - )], - false, - ); - - for rx in [&mut first_rx, &mut second_rx] { - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - } - - #[tokio::test] - async fn report_all_release_survives_modifier_release_order() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106:74;2u\x1b[106;1:3u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106:74;2:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_does_not_forward_release_for_consumed_prefix() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[98;5u\x1b[98;5:3u".to_vec()); - - assert_eq!(app.state.mode, Mode::Prefix); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_preserves_shift_enter_for_modify_other_keys_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - runtime.test_process_pty_bytes(b"\x1b[>4;1m"); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[13;2u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[27;2;13~") - ); - } - - #[tokio::test] - async fn route_client_input_splits_multi_event_payloads_before_forwarding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"ab".to_vec()); - - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"a")); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"b")); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_forwards_multilingual_ime_text_to_focused_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let text = "中日한🙂"; - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_capacity(80, 24, text.chars().count()); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(text.as_bytes().to_vec()); - - let mut forwarded = Vec::new(); - for _ in text.chars() { - let chunk = rx.recv().await.unwrap(); - forwarded.extend_from_slice(&chunk); - } - assert_eq!(forwarded, text.as_bytes()); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_forwards_long_voice_like_cjk_text_without_truncation() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(64); - let char_count = text.chars().count(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, char_count); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(text.as_bytes().to_vec()); - - let mut forwarded = Vec::new(); - for _ in 0..char_count { - let chunk = rx.recv().await.unwrap(); - forwarded.extend_from_slice(&chunk); - } - assert_eq!(forwarded, text.as_bytes()); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn route_client_input_handles_mouse_events() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - - // Send a mouse scroll-up event via SGR encoding. - let mouse_bytes = b"\x1b[<64;10;5M".to_vec(); - // This should not panic even though mouse handling is simplified - // in headless mode. - app.route_client_input(mouse_bytes); - // No assertions on specific behavior — just no panic. - } - - #[test] - fn route_client_input_advances_onboarding_modal() { - let mut app = test_app(); - app.state.mode = Mode::Onboarding; - - app.route_client_input(b"\r".to_vec()); - - assert_eq!(app.state.mode, Mode::Settings); - assert_eq!( - app.state.settings.section, - state::SettingsSection::Integrations - ); - } - - #[test] - fn route_client_input_pastes_bracketed_text_into_rename_modal() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameTab; - app.state.name_input = "2".into(); - app.state.name_input_replace_on_type = true; - - app.route_client_input(b"\x1b[200~feature/logs\x1b[201~".to_vec()); - - assert_eq!(app.state.name_input, "feature/logs"); - assert!(!app.state.name_input_replace_on_type); - } - - #[test] - fn route_client_input_rename_enter_submits_through_api_path() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("old")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameWorkspace; - app.state.name_input = "new".into(); - - app.route_client_input(b"\r".to_vec()); - - assert_eq!(app.state.workspaces[0].custom_name.as_deref(), Some("new")); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::WorkspaceRenamed) - })); - } - - #[test] - fn route_client_input_context_menu_enter_submits_through_api_path() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.confirm_close = false; - app.state.context_menu = Some(state::ContextMenuState { - kind: state::ContextMenuKind::Workspace { ws_idx: 1 }, - x: 2, - y: 2, - list: state::MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - - app.route_client_input(b"\r".to_vec()); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "a"); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::WorkspaceClosed) - })); - } - - #[test] - fn raw_ctrl_v_decodes_as_modal_paste_shortcut() { - let events = crate::raw_input::parse_raw_input_bytes_sync(&[0x16]); - let Some(crate::raw_input::RawInputEvent::Key(key)) = events.first() else { - panic!("expected ctrl-v key event"); - }; - - assert!(input::is_modal_paste_shortcut(&key.as_key_event())); - } - - #[test] - fn route_client_events_pastes_text_into_new_linked_worktree_modal() { - let mut app = test_app(); - app.state.mode = Mode::NewLinkedWorktree; - app.state.name_input = "generated-branch".into(); - app.state.name_input_replace_on_type = true; - app.state.worktree_create = Some(state::WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "generated-branch".into(), - checkout_path: "/repo/herdr-generated-branch".into(), - error: None, - creating: false, - }); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste( - "feature/linear-302".into(), - )], - true, - ); - - assert_eq!(app.state.name_input, "feature/linear-302"); - assert_eq!( - app.state - .worktree_create - .as_ref() - .map(|create| create.branch.as_str()), - Some("feature/linear-302") - ); - } - - #[tokio::test] - async fn route_client_events_pastes_only_into_popup() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("tiled"); - let focused = workspace.focused_pane_id().unwrap(); - let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, tiled_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12); - app.install_test_popup_runtime(popup_runtime); - assert!(app - .state - .should_capture_host_mouse_from(&app.terminal_runtimes)); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste("popup-only".into())], - true, - ); - - assert_eq!( - popup_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"popup-only") - ); - assert!(tiled_rx.try_recv().is_err()); - - app.route_client_events( - vec![raw_key( - KeyCode::Char('x'), - KeyModifiers::NONE, - KeyEventKind::Press, - )], - true, - ); - assert_eq!( - popup_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"x") - ); - assert!(tiled_rx.try_recv().is_err()); - - app.state.mode = Mode::Settings; - assert!( - app.handle_raw_input_event(raw_key( - KeyCode::Char('y'), - KeyModifiers::NONE, - KeyEventKind::Repeat, - )) - .await - ); - assert_eq!( - popup_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"y") - ); - assert!(tiled_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_events_discards_paste_when_popup_runtime_is_missing() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("tiled"); - let focused = workspace.focused_pane_id().unwrap(); - let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, tiled_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let install_missing_popup = |app: &mut App| { - let popup_terminal_id = crate::terminal::TerminalId::alloc(); - app.state.terminals.insert( - popup_terminal_id.clone(), - crate::terminal::TerminalState::new( - popup_terminal_id.clone(), - std::path::PathBuf::from("/popup"), - ), - ); - app.state.popup_pane = Some(state::PopupPaneState { - pane_id: crate::layout::PaneId::alloc(), - terminal_id: popup_terminal_id, - width: None, - height: None, - }); - }; - install_missing_popup(&mut app); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste("discard-me".into())], - true, - ); - - assert!(tiled_rx.try_recv().is_err()); - assert!(app.state.popup_pane.is_none()); - - install_missing_popup(&mut app); - assert!( - app.handle_raw_input_event(crate::raw_input::RawInputEvent::Paste( - "discard-monolithic".into(), - )) - .await - ); - assert!(tiled_rx.try_recv().is_err()); - assert!(app.state.popup_pane.is_none()); - } - - #[tokio::test] - async fn popup_mouse_motion_preserves_scrollback() { - let mut app = test_app(); - app.state.mode = Mode::Terminal; - app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24); - let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes( - 40, - 2, - 1024, - b"one\r\ntwo\r\nthree\r\n\x1b[?1003h\x1b[?1006h", - 4, - ); - popup_runtime.scroll_up(1); - assert!(popup_runtime - .scroll_metrics() - .is_some_and(|metrics| metrics.offset_from_bottom > 0)); - app.install_test_popup_runtime(popup_runtime); - let (_, inner) = - crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap(); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Moved, - column: inner.x + 1, - row: inner.y, - modifiers: crossterm::event::KeyModifiers::NONE, - }, - )], - true, - ); - - assert!(popup_rx.try_recv().is_ok()); - assert!(app - .popup_runtime() - .and_then(TerminalRuntime::scroll_metrics) - .is_some_and(|metrics| metrics.offset_from_bottom > 0)); - } - - #[tokio::test] - async fn route_client_events_routes_popup_mouse_when_global_capture_is_disabled() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("tiled"); - let focused = workspace.focused_pane_id().unwrap(); - let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); - tiled_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h"); - workspace.tabs[0].runtimes.insert(focused, tiled_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.mouse_capture = false; - app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24); - - let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12); - popup_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h"); - app.install_test_popup_runtime(popup_runtime); - let (_, inner) = - crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap(); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Down( - crossterm::event::MouseButton::Left, - ), - column: inner.x, - row: inner.y, - modifiers: crossterm::event::KeyModifiers::NONE, - }, - )], - true, - ); - - assert!(popup_rx.try_recv().is_ok()); - assert!(tiled_rx.try_recv().is_err()); - - assert!( - app.handle_raw_input_event(crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Down( - crossterm::event::MouseButton::Left, - ), - column: inner.x + 1, - row: inner.y, - modifiers: crossterm::event::KeyModifiers::NONE, - }, - )) - .await - ); - assert!(popup_rx.try_recv().is_ok()); - assert!(tiled_rx.try_recv().is_err()); - } - - #[test] - fn route_client_input_closes_release_notes_modal() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::ReleaseNotes; - app.state.release_notes = Some(release_notes_state()); - - app.route_client_input(b"\x1b".to_vec()); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.release_notes.is_none()); - } - - #[test] - fn route_client_input_closes_settings_modal() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Settings; - app.state.settings.original_theme = Some(app.state.theme_name.clone()); - app.state.settings.original_palette = Some(app.state.palette.clone()); - - app.route_client_input(b"\x1b".to_vec()); - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn route_client_input_updates_host_terminal_theme_from_osc_response() { - let mut app = test_app(); - - app.route_client_input(b"\x1b]11;#123456\x07\x1b]4;7;rgb:aaaa/bbbb/cccc\x1b\\".to_vec()); - - assert_eq!( - app.state.host_terminal_theme.background, - Some(crate::terminal_theme::RgbColor { - r: 0x12, - g: 0x34, - b: 0x56, - }) - ); - assert_eq!( - app.state.host_terminal_theme.palette[7], - Some(crate::terminal_theme::RgbColor { - r: 0xaa, - g: 0xbb, - b: 0xcc, - }) - ); - - app.route_client_input(crate::raw_input::GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec()); - assert_eq!( - app.state.host_terminal_theme.palette[7], - Some(crate::terminal_theme::RgbColor { - r: 0xaa, - g: 0xbb, - b: 0xcc, - }) - ); - - app.route_client_input(b"\x1b]4;7;rgb:dddd/eeee/ffff\x1b\\".to_vec()); - assert_eq!( - app.state.host_terminal_theme.palette[7], - Some(crate::terminal_theme::RgbColor { - r: 0xdd, - g: 0xee, - b: 0xff, - }) - ); - } - - #[tokio::test] - async fn route_client_input_does_not_forward_incomplete_osc_introducer_to_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 1); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b]".to_vec()); - - assert!(rx.try_recv().is_err()); - } - - #[test] - fn parse_raw_input_bytes_with_ranges_tracks_offsets() { - // Verify that the range-aware parser correctly tracks byte offsets - // for events within a multi-event input buffer. - let input = b"\x1b[Aa".to_vec(); // Up arrow + 'a' - let events = crate::raw_input::parse_raw_input_bytes_with_ranges(&input); - - assert_eq!(events.len(), 2, "should parse Up arrow and 'a'"); - // Up arrow: \x1b[A = 3 bytes starting at offset 0 - assert_eq!(events[0].start, 0); - assert_eq!(events[0].len, 3); - // 'a': 1 byte starting at offset 3 - assert_eq!(events[1].start, 3); - assert_eq!(events[1].len, 1); - - // Verify the raw bytes for each event are correct. - assert_eq!( - &input[events[0].start..events[0].start + events[0].len], - b"\x1b[A" - ); - assert_eq!( - &input[events[1].start..events[1].start + events[1].len], - b"a" - ); - } } |
