Skip to main content

par_term/app/
window_state.rs

1//! Per-window state for multi-window terminal emulator
2//!
3//! This module contains `WindowState`, which holds all state specific to a single window,
4//! including its renderer, tab manager, input handler, and UI components.
5
6use crate::ai_inspector::chat::ChatMessage;
7use crate::ai_inspector::panel::{AIInspectorPanel, InspectorAction};
8use crate::app::anti_idle::should_send_keep_alive;
9use crate::app::debug_state::DebugState;
10use crate::badge::{BadgeState, render_badge};
11use crate::cell_renderer::PaneViewport;
12use crate::clipboard_history_ui::{ClipboardHistoryAction, ClipboardHistoryUI};
13use crate::close_confirmation_ui::{CloseConfirmAction, CloseConfirmationUI};
14use crate::command_history::CommandHistory;
15use crate::command_history_ui::{CommandHistoryAction, CommandHistoryUI};
16use crate::config::{
17    Config, CursorShaderMetadataCache, CursorStyle, ShaderInstallPrompt, ShaderMetadataCache,
18};
19use crate::help_ui::HelpUI;
20use crate::input::InputHandler;
21use crate::integrations_ui::{IntegrationsResponse, IntegrationsUI};
22use crate::keybindings::KeybindingRegistry;
23use crate::paste_special_ui::{PasteSpecialAction, PasteSpecialUI};
24use crate::profile::{ProfileManager, storage as profile_storage};
25use crate::profile_drawer_ui::{ProfileDrawerAction, ProfileDrawerUI};
26use crate::progress_bar::{ProgressBarSnapshot, render_progress_bars};
27use crate::quit_confirmation_ui::{QuitConfirmAction, QuitConfirmationUI};
28use crate::remote_shell_install_ui::{RemoteShellInstallAction, RemoteShellInstallUI};
29use crate::renderer::{
30    DividerRenderInfo, PaneDividerSettings, PaneRenderInfo, PaneTitleInfo, Renderer,
31};
32use crate::scrollback_metadata::ScrollbackMark;
33use crate::search::SearchUI;
34use crate::selection::SelectionMode;
35use crate::shader_install_ui::{ShaderInstallResponse, ShaderInstallUI};
36use crate::shader_watcher::{ShaderReloadEvent, ShaderType, ShaderWatcher};
37use crate::smart_selection::SmartSelectionCache;
38use crate::ssh_connect_ui::{SshConnectAction, SshConnectUI};
39use crate::status_bar::StatusBarUI;
40use crate::tab::{TabId, TabManager};
41use crate::tab_bar_ui::{TabBarAction, TabBarUI};
42use crate::tmux::{TmuxSession, TmuxSync};
43use crate::tmux_session_picker_ui::{SessionPickerAction, TmuxSessionPickerUI};
44use crate::tmux_status_bar_ui::TmuxStatusBarUI;
45use anyhow::Result;
46use par_term_acp::{
47    Agent, AgentConfig, AgentMessage, AgentStatus, ClientCapabilities, FsCapabilities, SafePaths,
48    discover_agents,
49};
50use par_term_emu_core_rust::cursor::CursorStyle as TermCursorStyle;
51use std::sync::Arc;
52use tokio::runtime::Runtime;
53use tokio::sync::mpsc;
54use wgpu::SurfaceError;
55use winit::dpi::PhysicalSize;
56use winit::window::Window;
57
58/// Renderer sizing info needed for split pane calculations
59struct RendererSizing {
60    size: PhysicalSize<u32>,
61    content_offset_y: f32,
62    content_offset_x: f32,
63    content_inset_bottom: f32,
64    content_inset_right: f32,
65    cell_width: f32,
66    cell_height: f32,
67    padding: f32,
68    status_bar_height: f32,
69    scale_factor: f32,
70}
71
72/// Pane render data tuple for split pane rendering
73type PaneRenderData = (
74    PaneViewport,
75    Vec<crate::cell_renderer::Cell>,
76    (usize, usize),
77    Option<(usize, usize)>,
78    f32,
79    Vec<ScrollbackMark>,
80    usize,                               // scrollback_len
81    usize,                               // scroll_offset
82    Option<crate::pane::PaneBackground>, // per-pane background
83);
84
85/// Per-window state that manages a single terminal window with multiple tabs
86pub struct WindowState {
87    pub(crate) config: Config,
88    pub(crate) window: Option<Arc<Window>>,
89    pub(crate) renderer: Option<Renderer>,
90    pub(crate) input_handler: InputHandler,
91    pub(crate) runtime: Arc<Runtime>,
92
93    /// Tab manager for handling multiple terminal tabs
94    pub(crate) tab_manager: TabManager,
95    /// Tab bar UI
96    pub(crate) tab_bar_ui: TabBarUI,
97    /// tmux status bar UI
98    pub(crate) tmux_status_bar_ui: TmuxStatusBarUI,
99    /// Custom status bar UI
100    pub(crate) status_bar_ui: StatusBarUI,
101
102    pub(crate) debug: DebugState,
103
104    /// Cursor opacity for smooth fade animation (0.0 = invisible, 1.0 = fully visible)
105    pub(crate) cursor_opacity: f32,
106    /// Time of last cursor blink toggle
107    pub(crate) last_cursor_blink: Option<std::time::Instant>,
108    /// Time of last key press (to reset cursor blink)
109    pub(crate) last_key_press: Option<std::time::Instant>,
110    /// Whether window is currently in fullscreen mode
111    pub(crate) is_fullscreen: bool,
112    /// egui context for GUI rendering
113    pub(crate) egui_ctx: Option<egui::Context>,
114    /// egui-winit state for event handling
115    pub(crate) egui_state: Option<egui_winit::State>,
116    /// Pending egui events to inject into next frame's raw_input.
117    /// Used when macOS menu accelerators intercept Cmd+V/C/A before egui sees them
118    /// while an egui overlay (profile modal, search, etc.) is active.
119    pub(crate) pending_egui_events: Vec<egui::Event>,
120    /// Whether egui has completed its first ctx.run() call
121    /// Before first run, egui's is_using_pointer() returns unreliable results
122    pub(crate) egui_initialized: bool,
123    /// Cache for parsed shader metadata (used for config resolution)
124    pub(crate) shader_metadata_cache: ShaderMetadataCache,
125    /// Cache for parsed cursor shader metadata (used for config resolution)
126    pub(crate) cursor_shader_metadata_cache: CursorShaderMetadataCache,
127    /// Help UI manager
128    pub(crate) help_ui: HelpUI,
129    /// Clipboard history UI manager
130    pub(crate) clipboard_history_ui: ClipboardHistoryUI,
131    /// Command history UI manager (fuzzy search)
132    pub(crate) command_history_ui: CommandHistoryUI,
133    /// Persistent command history
134    pub(crate) command_history: CommandHistory,
135    /// Commands already synced from marks to persistent history (avoids repeated adds)
136    synced_commands: std::collections::HashSet<String>,
137    /// Paste special UI manager (text transformations)
138    pub(crate) paste_special_ui: PasteSpecialUI,
139    /// tmux session picker UI
140    pub(crate) tmux_session_picker_ui: TmuxSessionPickerUI,
141    /// Search UI manager
142    pub(crate) search_ui: SearchUI,
143    /// AI Inspector side panel
144    pub(crate) ai_inspector: AIInspectorPanel,
145    /// Last known AI Inspector panel consumed width (logical pixels).
146    /// Used to detect width changes from drag-resizing and trigger terminal reflow.
147    pub(crate) last_inspector_width: f32,
148    /// ACP agent message receiver
149    pub(crate) agent_rx: Option<mpsc::UnboundedReceiver<AgentMessage>>,
150    /// ACP agent message sender (kept to signal prompt completion)
151    pub(crate) agent_tx: Option<mpsc::UnboundedSender<AgentMessage>>,
152    /// ACP agent (managed via tokio)
153    pub(crate) agent: Option<Arc<tokio::sync::Mutex<Agent>>>,
154    /// ACP JSON-RPC client for sending responses without locking the agent.
155    /// Stored separately to avoid deadlocks: `send_prompt` holds the agent lock
156    /// while waiting for the prompt response, but the agent's tool calls
157    /// (e.g. `fs/readTextFile`) need us to respond via this same client.
158    pub(crate) agent_client: Option<Arc<par_term_acp::JsonRpcClient>>,
159    /// Available agent configs
160    pub(crate) available_agents: Vec<AgentConfig>,
161    /// Shader install prompt UI
162    pub(crate) shader_install_ui: ShaderInstallUI,
163    /// Receiver for shader installation results (from background thread)
164    pub(crate) shader_install_receiver: Option<std::sync::mpsc::Receiver<Result<usize, String>>>,
165    /// Combined integrations welcome dialog UI
166    pub(crate) integrations_ui: IntegrationsUI,
167    /// Close confirmation dialog UI (for tabs with running jobs)
168    pub(crate) close_confirmation_ui: CloseConfirmationUI,
169    /// Quit confirmation dialog UI (prompt before closing window)
170    pub(crate) quit_confirmation_ui: QuitConfirmationUI,
171    /// Remote shell integration install dialog UI
172    pub(crate) remote_shell_install_ui: RemoteShellInstallUI,
173    /// SSH Quick Connect dialog UI
174    pub(crate) ssh_connect_ui: SshConnectUI,
175    /// Whether terminal session recording is active
176    pub(crate) is_recording: bool,
177    /// When recording started
178    #[allow(dead_code)]
179    pub(crate) recording_start_time: Option<std::time::Instant>,
180    /// Flag to indicate shutdown is in progress
181    pub(crate) is_shutting_down: bool,
182    /// Window index (1-based) for display in title bar
183    pub(crate) window_index: usize,
184
185    // Smart redraw tracking (event-driven rendering)
186    /// Whether we need to render next frame
187    pub(crate) needs_redraw: bool,
188    /// Set when an agent/MCP config update was applied — signals WindowManager to
189    /// sync its own config copy so subsequent saves don't overwrite agent changes.
190    pub(crate) config_changed_by_agent: bool,
191    /// When to blink cursor next
192    pub(crate) cursor_blink_timer: Option<std::time::Instant>,
193    /// Whether we need to rebuild renderer after font-related changes
194    pub(crate) pending_font_rebuild: bool,
195
196    // Focus state for power saving
197    /// Whether the window currently has focus
198    pub(crate) is_focused: bool,
199    /// Last time a frame was rendered (for FPS throttling when unfocused)
200    pub(crate) last_render_time: Option<std::time::Instant>,
201
202    // Flicker reduction state
203    /// When cursor was last hidden (for reduce_flicker feature)
204    pub(crate) cursor_hidden_since: Option<std::time::Instant>,
205    /// Whether we have pending terminal updates deferred due to cursor being hidden
206    pub(crate) flicker_pending_render: bool,
207
208    // Throughput mode state
209    /// When throughput mode batching started (for render interval timing)
210    pub(crate) throughput_batch_start: Option<std::time::Instant>,
211
212    // Shader hot reload
213    /// Shader file watcher for hot reload support
214    pub(crate) shader_watcher: Option<ShaderWatcher>,
215    /// Config file watcher for automatic reload (e.g., when ACP agent modifies config.yaml)
216    pub(crate) config_watcher: Option<crate::config::watcher::ConfigWatcher>,
217    /// Watcher for `.config-update.json` written by the MCP server
218    pub(crate) config_update_watcher: Option<crate::config::watcher::ConfigWatcher>,
219    /// Last shader reload error message (for display in UI)
220    pub(crate) shader_reload_error: Option<String>,
221    /// Background shader reload result: None = no change, Some(None) = success, Some(Some(err)) = error
222    /// Used to propagate hot reload results to standalone settings window
223    pub(crate) background_shader_reload_result: Option<Option<String>>,
224    /// Cursor shader reload result: None = no change, Some(None) = success, Some(Some(err)) = error
225    /// Used to propagate hot reload results to standalone settings window
226    pub(crate) cursor_shader_reload_result: Option<Option<String>>,
227
228    /// Flag to signal that the settings window should be opened
229    /// This is set by keyboard handlers and consumed by the window manager
230    pub(crate) open_settings_window_requested: bool,
231
232    /// Pending arrangement restore request (name of arrangement to restore)
233    pub(crate) pending_arrangement_restore: Option<String>,
234
235    /// Flag to request reload of dynamic profiles
236    pub(crate) reload_dynamic_profiles_requested: bool,
237
238    // Profile management
239    /// Profile manager for storing and managing terminal profiles
240    pub(crate) profile_manager: ProfileManager,
241    /// Profile drawer UI (collapsible side panel)
242    pub(crate) profile_drawer_ui: ProfileDrawerUI,
243    /// Flag to signal that the settings window should open to the Profiles tab
244    pub(crate) open_settings_profiles_tab: bool,
245    /// Flag to indicate profiles menu needs to be updated in the main menu
246    pub(crate) profiles_menu_needs_update: bool,
247    /// Track if we blocked a mouse press for UI - also block the corresponding release
248    pub(crate) ui_consumed_mouse_press: bool,
249    /// Eat the first mouse click after window focus to prevent forwarding to PTY.
250    /// Without this, clicking to focus the window sends a mouse event to tmux (or
251    /// other mouse-aware apps), which can trigger a zero-char selection that clears
252    /// the system clipboard — destroying any clipboard image.
253    pub(crate) focus_click_pending: bool,
254
255    // Resize overlay state
256    /// Whether the resize overlay is currently visible
257    pub(crate) resize_overlay_visible: bool,
258    /// When to hide the resize overlay (after resize stops)
259    pub(crate) resize_overlay_hide_time: Option<std::time::Instant>,
260    /// Current resize dimensions: (width_px, height_px, cols, rows)
261    pub(crate) resize_dimensions: Option<(u32, u32, usize, usize)>,
262
263    // Toast notification state
264    /// Current toast message to display
265    pub(crate) toast_message: Option<String>,
266    /// When to hide the toast notification
267    pub(crate) toast_hide_time: Option<std::time::Instant>,
268
269    // Pane identification overlay
270    /// When to hide the pane index overlay
271    pub(crate) pane_identify_hide_time: Option<std::time::Instant>,
272
273    /// Recently closed tab metadata for session undo (reopen closed tab)
274    pub(crate) closed_tabs: std::collections::VecDeque<super::tab_ops::ClosedTabInfo>,
275
276    /// Keybinding registry for user-defined keyboard shortcuts
277    pub(crate) keybinding_registry: KeybindingRegistry,
278
279    /// Cache for compiled smart selection regex patterns
280    pub(crate) smart_selection_cache: SmartSelectionCache,
281
282    // tmux integration state
283    /// tmux control mode session (if connected)
284    pub(crate) tmux_session: Option<TmuxSession>,
285    /// tmux state synchronization manager
286    pub(crate) tmux_sync: TmuxSync,
287    /// Current tmux session name (for window title display)
288    pub(crate) tmux_session_name: Option<String>,
289    /// Tab ID where the tmux gateway connection lives (where we write commands)
290    pub(crate) tmux_gateway_tab_id: Option<TabId>,
291    /// Parsed prefix key from config (cached for performance)
292    pub(crate) tmux_prefix_key: Option<crate::tmux::PrefixKey>,
293    /// Prefix key state (whether we're waiting for command key)
294    pub(crate) tmux_prefix_state: crate::tmux::PrefixState,
295    /// Mapping from tmux pane IDs to native pane IDs for output routing
296    pub(crate) tmux_pane_to_native_pane:
297        std::collections::HashMap<crate::tmux::TmuxPaneId, crate::pane::PaneId>,
298    /// Reverse mapping from native pane IDs to tmux pane IDs for input routing
299    pub(crate) native_pane_to_tmux_pane:
300        std::collections::HashMap<crate::pane::PaneId, crate::tmux::TmuxPaneId>,
301
302    // Broadcast input mode
303    /// Whether keyboard input is broadcast to all panes in current tab
304    pub(crate) broadcast_input: bool,
305
306    // Badge overlay
307    /// Badge state for session information display
308    pub(crate) badge_state: BadgeState,
309
310    // Copy mode (vi-style keyboard text selection)
311    /// Copy mode state machine
312    pub(crate) copy_mode: crate::copy_mode::CopyModeState,
313
314    // File transfer state
315    /// File transfer UI state (active transfers, pending saves/uploads, dialog state)
316    pub(crate) file_transfer_state: crate::app::file_transfers::FileTransferState,
317
318    /// Whether to show the update dialog overlay (set when user clicks the update widget)
319    pub(crate) show_update_dialog: bool,
320
321    /// Last update check result (for update dialog)
322    pub(crate) last_update_result: Option<crate::update_checker::UpdateCheckResult>,
323    /// Detected installation type
324    pub(crate) installation_type: par_term_settings_ui::InstallationType,
325
326    /// Whether an update install is in progress (from the update dialog)
327    pub(crate) update_installing: bool,
328    /// Status message from the update install
329    pub(crate) update_install_status: Option<String>,
330    /// Channel receiver for async update install result
331    pub(crate) update_install_receiver:
332        Option<std::sync::mpsc::Receiver<Result<crate::self_updater::UpdateResult, String>>>,
333}
334
335/// Extract an `f32` from a JSON value that may be an integer or float.
336fn json_as_f32(value: &serde_json::Value) -> Result<f32, String> {
337    if let Some(f) = value.as_f64() {
338        Ok(f as f32)
339    } else if let Some(i) = value.as_i64() {
340        Ok(i as f32)
341    } else {
342        Err("expected number".to_string())
343    }
344}
345
346impl WindowState {
347    /// Create a new window state with the given configuration
348    pub fn new(config: Config, runtime: Arc<Runtime>) -> Self {
349        let keybinding_registry = KeybindingRegistry::from_config(&config.keybindings);
350        let shaders_dir = Config::shaders_dir();
351        let tmux_prefix_key = crate::tmux::PrefixKey::parse(&config.tmux_prefix_key);
352
353        let mut input_handler = InputHandler::new();
354        // Initialize Option/Alt key modes from config
355        input_handler
356            .update_option_key_modes(config.left_option_key_mode, config.right_option_key_mode);
357
358        // Load profiles from disk
359        let profile_manager = match profile_storage::load_profiles() {
360            Ok(manager) => manager,
361            Err(e) => {
362                log::warn!("Failed to load profiles: {}", e);
363                ProfileManager::new()
364            }
365        };
366
367        // Create badge state and AI inspector before moving config
368        let badge_state = BadgeState::new(&config);
369        let ai_inspector = AIInspectorPanel::new(&config);
370
371        // Discover available ACP agents
372        let config_dir = dirs::config_dir().unwrap_or_default().join("par-term");
373        let available_agents = discover_agents(&config_dir);
374        let command_history_max = config.command_history_max_entries;
375
376        Self {
377            config,
378            window: None,
379            renderer: None,
380            input_handler,
381            runtime,
382
383            tab_manager: TabManager::new(),
384            tab_bar_ui: TabBarUI::new(),
385            tmux_status_bar_ui: TmuxStatusBarUI::new(),
386            status_bar_ui: StatusBarUI::new(),
387
388            debug: DebugState::new(),
389
390            cursor_opacity: 1.0,
391            last_cursor_blink: None,
392            last_key_press: None,
393            is_fullscreen: false,
394            egui_ctx: None,
395            egui_state: None,
396            pending_egui_events: Vec::new(),
397            egui_initialized: false,
398            shader_metadata_cache: ShaderMetadataCache::with_shaders_dir(shaders_dir.clone()),
399            cursor_shader_metadata_cache: CursorShaderMetadataCache::with_shaders_dir(shaders_dir),
400            help_ui: HelpUI::new(),
401            clipboard_history_ui: ClipboardHistoryUI::new(),
402            command_history_ui: CommandHistoryUI::new(),
403            command_history: {
404                let mut ch = CommandHistory::new(command_history_max);
405                ch.load();
406                ch
407            },
408            synced_commands: std::collections::HashSet::new(),
409            paste_special_ui: PasteSpecialUI::new(),
410            tmux_session_picker_ui: TmuxSessionPickerUI::new(),
411            search_ui: SearchUI::new(),
412            ai_inspector,
413            last_inspector_width: 0.0,
414            agent_rx: None,
415            agent_tx: None,
416            agent: None,
417            agent_client: None,
418            available_agents,
419            shader_install_ui: ShaderInstallUI::new(),
420            shader_install_receiver: None,
421            integrations_ui: IntegrationsUI::new(),
422            close_confirmation_ui: CloseConfirmationUI::new(),
423            quit_confirmation_ui: QuitConfirmationUI::new(),
424            remote_shell_install_ui: RemoteShellInstallUI::new(),
425            ssh_connect_ui: SshConnectUI::new(),
426            is_recording: false,
427            recording_start_time: None,
428            is_shutting_down: false,
429            window_index: 1, // Will be set by WindowManager when window is created
430
431            needs_redraw: true,
432            config_changed_by_agent: false,
433            cursor_blink_timer: None,
434            pending_font_rebuild: false,
435
436            is_focused: true, // Assume focused on creation
437            last_render_time: None,
438
439            cursor_hidden_since: None,
440            flicker_pending_render: false,
441
442            throughput_batch_start: None,
443
444            shader_watcher: None,
445            config_watcher: None,
446            config_update_watcher: None,
447            shader_reload_error: None,
448            background_shader_reload_result: None,
449            cursor_shader_reload_result: None,
450
451            open_settings_window_requested: false,
452            pending_arrangement_restore: None,
453            reload_dynamic_profiles_requested: false,
454
455            profile_manager,
456            profile_drawer_ui: ProfileDrawerUI::new(),
457            open_settings_profiles_tab: false,
458            profiles_menu_needs_update: true, // Update menu on startup
459            ui_consumed_mouse_press: false,
460            focus_click_pending: false,
461
462            resize_overlay_visible: false,
463            resize_overlay_hide_time: None,
464            resize_dimensions: None,
465
466            toast_message: None,
467            toast_hide_time: None,
468            pane_identify_hide_time: None,
469            closed_tabs: std::collections::VecDeque::new(),
470
471            keybinding_registry,
472
473            smart_selection_cache: SmartSelectionCache::new(),
474
475            tmux_session: None,
476            tmux_sync: TmuxSync::new(),
477            tmux_session_name: None,
478            tmux_gateway_tab_id: None,
479            tmux_prefix_key,
480            tmux_prefix_state: crate::tmux::PrefixState::new(),
481            tmux_pane_to_native_pane: std::collections::HashMap::new(),
482            native_pane_to_tmux_pane: std::collections::HashMap::new(),
483
484            broadcast_input: false,
485
486            badge_state,
487
488            copy_mode: crate::copy_mode::CopyModeState::new(),
489
490            file_transfer_state: crate::app::file_transfers::FileTransferState::default(),
491
492            show_update_dialog: false,
493
494            last_update_result: None,
495            installation_type: par_term_settings_ui::InstallationType::StandaloneBinary,
496
497            update_installing: false,
498            update_install_status: None,
499            update_install_receiver: None,
500        }
501    }
502
503    /// Format window title with optional window number
504    /// This should be used everywhere a title is set to ensure consistency
505    pub(crate) fn format_title(&self, base_title: &str) -> String {
506        if self.config.show_window_number {
507            format!("{} [{}]", base_title, self.window_index)
508        } else {
509            base_title.to_string()
510        }
511    }
512
513    // ========================================================================
514    // Active Tab State Accessors (compatibility - may be useful later)
515    // ========================================================================
516    #[allow(dead_code)]
517    pub(crate) fn terminal(
518        &self,
519    ) -> Option<&Arc<tokio::sync::Mutex<crate::terminal::TerminalManager>>> {
520        self.active_terminal()
521    }
522
523    #[allow(dead_code)]
524    pub(crate) fn scroll_state(&self) -> Option<&crate::scroll_state::ScrollState> {
525        self.tab_manager.active_tab().map(|t| &t.scroll_state)
526    }
527
528    #[allow(dead_code)]
529    pub(crate) fn scroll_state_mut(&mut self) -> Option<&mut crate::scroll_state::ScrollState> {
530        self.tab_manager
531            .active_tab_mut()
532            .map(|t| &mut t.scroll_state)
533    }
534
535    #[allow(dead_code)]
536    pub(crate) fn mouse(&self) -> Option<&crate::app::mouse::MouseState> {
537        self.tab_manager.active_tab().map(|t| &t.mouse)
538    }
539
540    #[allow(dead_code)]
541    pub(crate) fn mouse_mut(&mut self) -> Option<&mut crate::app::mouse::MouseState> {
542        self.tab_manager.active_tab_mut().map(|t| &mut t.mouse)
543    }
544
545    #[allow(dead_code)]
546    pub(crate) fn bell(&self) -> Option<&crate::app::bell::BellState> {
547        self.tab_manager.active_tab().map(|t| &t.bell)
548    }
549
550    #[allow(dead_code)]
551    pub(crate) fn bell_mut(&mut self) -> Option<&mut crate::app::bell::BellState> {
552        self.tab_manager.active_tab_mut().map(|t| &mut t.bell)
553    }
554
555    #[allow(dead_code)]
556    pub(crate) fn cache(&self) -> Option<&crate::app::render_cache::RenderCache> {
557        self.tab_manager.active_tab().map(|t| &t.cache)
558    }
559
560    #[allow(dead_code)]
561    pub(crate) fn cache_mut(&mut self) -> Option<&mut crate::app::render_cache::RenderCache> {
562        self.tab_manager.active_tab_mut().map(|t| &mut t.cache)
563    }
564
565    #[allow(dead_code)]
566    pub(crate) fn refresh_task(&self) -> Option<&Option<tokio::task::JoinHandle<()>>> {
567        self.tab_manager.active_tab().map(|t| &t.refresh_task)
568    }
569
570    #[allow(dead_code)]
571    pub(crate) fn abort_refresh_task(&mut self) {
572        if let Some(tab) = self.tab_manager.active_tab_mut()
573            && let Some(task) = tab.refresh_task.take()
574        {
575            task.abort();
576        }
577    }
578
579    /// Extract a substring based on character columns to avoid UTF-8 slicing panics
580    pub(crate) fn extract_columns(line: &str, start_col: usize, end_col: Option<usize>) -> String {
581        let mut extracted = String::new();
582        let end_bound = end_col.unwrap_or(usize::MAX);
583
584        if start_col > end_bound {
585            return extracted;
586        }
587
588        for (idx, ch) in line.chars().enumerate() {
589            if idx > end_bound {
590                break;
591            }
592
593            if idx >= start_col {
594                extracted.push(ch);
595            }
596        }
597
598        extracted
599    }
600
601    // ========================================================================
602    // DRY Helper Methods
603    // ========================================================================
604
605    /// Invalidate the active tab's cell cache, forcing regeneration on next render
606    #[inline]
607    pub(crate) fn invalidate_tab_cache(&mut self) {
608        if let Some(tab) = self.tab_manager.active_tab_mut() {
609            tab.cache.cells = None;
610        }
611    }
612
613    /// Request window redraw if window exists
614    #[inline]
615    pub(crate) fn request_redraw(&self) {
616        if let Some(window) = &self.window {
617            crate::debug_trace!("REDRAW", "request_redraw called");
618            window.request_redraw();
619        } else {
620            crate::debug_trace!("REDRAW", "request_redraw called but no window");
621        }
622    }
623
624    /// Invalidate cache and request redraw (common pattern after state changes)
625    #[inline]
626    #[allow(dead_code)] // Available for future use, cannot be used inside renderer borrow blocks
627    pub(crate) fn invalidate_and_redraw(&mut self) {
628        self.invalidate_tab_cache();
629        self.needs_redraw = true;
630        self.request_redraw();
631    }
632
633    /// Clear renderer cells and invalidate cache (used when switching tabs)
634    pub(crate) fn clear_and_invalidate(&mut self) {
635        if let Some(renderer) = &mut self.renderer {
636            renderer.clear_all_cells();
637        }
638        self.invalidate_tab_cache();
639        self.needs_redraw = true;
640        self.request_redraw();
641    }
642
643    /// Rebuild the renderer after font-related changes and resize the terminal accordingly
644    pub(crate) fn rebuild_renderer(&mut self) -> Result<()> {
645        use crate::app::renderer_init::RendererInitParams;
646
647        let window = if let Some(w) = &self.window {
648            Arc::clone(w)
649        } else {
650            return Ok(()); // Nothing to rebuild yet
651        };
652
653        // Create renderer using DRY init params
654        let theme = self.config.load_theme();
655        // Get shader metadata from cache for full 3-tier resolution
656        let metadata = self
657            .config
658            .custom_shader
659            .as_ref()
660            .and_then(|name| self.shader_metadata_cache.get(name).cloned());
661        // Get cursor shader metadata from cache for full 3-tier resolution
662        let cursor_metadata = self
663            .config
664            .cursor_shader
665            .as_ref()
666            .and_then(|name| self.cursor_shader_metadata_cache.get(name).cloned());
667        let params = RendererInitParams::from_config(
668            &self.config,
669            &theme,
670            metadata.as_ref(),
671            cursor_metadata.as_ref(),
672        );
673
674        // Drop the old renderer BEFORE creating a new one.
675        // wgpu only allows one surface per window, so the old surface must be
676        // released before we can create a new one.
677        self.renderer = None;
678
679        let mut renderer = self
680            .runtime
681            .block_on(params.create_renderer(Arc::clone(&window)))?;
682
683        let (cols, rows) = renderer.grid_size();
684        let cell_width = renderer.cell_width();
685        let cell_height = renderer.cell_height();
686        let width_px = (cols as f32 * cell_width) as usize;
687        let height_px = (rows as f32 * cell_height) as usize;
688
689        // Resize all tabs' terminals
690        for tab in self.tab_manager.tabs_mut() {
691            if let Ok(mut term) = tab.terminal.try_lock() {
692                let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
693                term.set_cell_dimensions(cell_width as u32, cell_height as u32);
694                term.set_theme(self.config.load_theme());
695            }
696            tab.cache.cells = None;
697        }
698
699        // Apply cursor shader configuration
700        self.apply_cursor_shader_config(&mut renderer, &params);
701
702        self.renderer = Some(renderer);
703        self.needs_redraw = true;
704
705        // Re-apply AI Inspector panel inset to the new renderer.
706        // The old renderer had the correct content_inset_right but the new one
707        // starts with 0.0. Force last_inspector_width to 0 so sync detects the change.
708        self.last_inspector_width = 0.0;
709        self.sync_ai_inspector_width();
710
711        // Reset egui with preserved memory (window positions, collapse state)
712        self.init_egui(&window, true);
713        self.request_redraw();
714
715        Ok(())
716    }
717
718    /// Initialize the window asynchronously
719    pub(crate) async fn initialize_async(&mut self, window: Window) -> Result<()> {
720        use crate::app::renderer_init::RendererInitParams;
721
722        // Enable IME (Input Method Editor) to receive all character events including Space
723        window.set_ime_allowed(true);
724        log::debug!("IME enabled for character input");
725
726        // Detect system theme at startup and apply if auto_dark_mode is enabled
727        if self.config.auto_dark_mode {
728            let is_dark = window
729                .theme()
730                .is_none_or(|t| t == winit::window::Theme::Dark);
731            if self.config.apply_system_theme(is_dark) {
732                log::info!(
733                    "Auto dark mode: detected {} system theme, using theme: {}",
734                    if is_dark { "dark" } else { "light" },
735                    self.config.theme
736                );
737            }
738        }
739
740        // Detect system theme at startup and apply tab style if tab_style is Automatic
741        {
742            let is_dark = window
743                .theme()
744                .is_none_or(|t| t == winit::window::Theme::Dark);
745            if self.config.apply_system_tab_style(is_dark) {
746                log::info!(
747                    "Auto tab style: detected {} system theme, applying {} tab style",
748                    if is_dark { "dark" } else { "light" },
749                    if is_dark {
750                        self.config.dark_tab_style.display_name()
751                    } else {
752                        self.config.light_tab_style.display_name()
753                    }
754                );
755            }
756        }
757
758        let window = Arc::new(window);
759
760        // Initialize egui context and state (no memory to preserve on first init)
761        self.init_egui(&window, false);
762
763        // Create renderer using DRY init params
764        let theme = self.config.load_theme();
765        // Get shader metadata from cache for full 3-tier resolution
766        let metadata = self
767            .config
768            .custom_shader
769            .as_ref()
770            .and_then(|name| self.shader_metadata_cache.get(name).cloned());
771        // Get cursor shader metadata from cache for full 3-tier resolution
772        let cursor_metadata = self
773            .config
774            .cursor_shader
775            .as_ref()
776            .and_then(|name| self.cursor_shader_metadata_cache.get(name).cloned());
777        let params = RendererInitParams::from_config(
778            &self.config,
779            &theme,
780            metadata.as_ref(),
781            cursor_metadata.as_ref(),
782        );
783        let mut renderer = params.create_renderer(Arc::clone(&window)).await?;
784
785        // macOS: Configure CAMetalLayer (transparency + performance)
786        // This MUST be done AFTER creating the wgpu surface/renderer
787        // so that the CAMetalLayer has been created by wgpu
788        #[cfg(target_os = "macos")]
789        {
790            if let Err(e) = crate::macos_metal::configure_metal_layer_for_performance(&window) {
791                log::warn!("Failed to configure Metal layer: {}", e);
792                log::warn!(
793                    "Continuing anyway - may experience reduced FPS or missing transparency on macOS"
794                );
795            }
796            // Set initial layer opacity to match config (content only, frame unaffected)
797            if let Err(e) = crate::macos_metal::set_layer_opacity(&window, 1.0) {
798                log::warn!("Failed to set initial Metal layer opacity: {}", e);
799            }
800            // Apply initial blur settings if enabled
801            if self.config.blur_enabled
802                && self.config.window_opacity < 1.0
803                && let Err(e) = crate::macos_blur::set_window_blur(&window, self.config.blur_radius)
804            {
805                log::warn!("Failed to set initial window blur: {}", e);
806            }
807        }
808
809        // Apply cursor shader configuration
810        self.apply_cursor_shader_config(&mut renderer, &params);
811
812        // Set tab bar offsets BEFORE creating the first tab
813        // This ensures the terminal is sized correctly from the start
814        // Use 1 as tab count since we're about to create the first tab
815        let initial_tab_bar_height = self.tab_bar_ui.get_height(1, &self.config);
816        let initial_tab_bar_width = self.tab_bar_ui.get_width(1, &self.config);
817        let (initial_cols, initial_rows) = renderer.grid_size();
818        log::info!(
819            "Tab bar init: mode={:?}, position={:?}, height={:.1}, width={:.1}, initial_grid={}x{}, content_offset_y_before={:.1}",
820            self.config.tab_bar_mode,
821            self.config.tab_bar_position,
822            initial_tab_bar_height,
823            initial_tab_bar_width,
824            initial_cols,
825            initial_rows,
826            renderer.content_offset_y()
827        );
828        self.apply_tab_bar_offsets(&mut renderer, initial_tab_bar_height, initial_tab_bar_width);
829
830        // Get the renderer's grid size BEFORE storing it (and before creating tabs)
831        // This ensures the shell starts with correct dimensions that account for tab bar
832        let (renderer_cols, renderer_rows) = renderer.grid_size();
833        let cell_width = renderer.cell_width();
834        let cell_height = renderer.cell_height();
835
836        self.window = Some(Arc::clone(&window));
837        self.renderer = Some(renderer);
838
839        // Initialize shader watcher if hot reload is enabled
840        self.init_shader_watcher();
841
842        // Initialize config file watcher for automatic reload
843        self.init_config_watcher();
844
845        // Initialize config-update file watcher (MCP server writes here)
846        self.init_config_update_watcher();
847
848        // Sync status bar monitor state based on config
849        self.status_bar_ui.sync_monitor_state(&self.config);
850
851        // Create the first tab with the correct grid size from the renderer
852        // This ensures the shell is spawned with dimensions that account for tab bar
853        log::info!(
854            "Creating first tab with grid size {}x{} (accounting for tab bar)",
855            renderer_cols,
856            renderer_rows
857        );
858        let tab_id = self.tab_manager.new_tab(
859            &self.config,
860            Arc::clone(&self.runtime),
861            false,                                // First tab doesn't inherit cwd
862            Some((renderer_cols, renderer_rows)), // Pass correct grid size
863        )?;
864
865        // Set cell dimensions on the terminal (for TIOCGWINSZ pixel size reporting)
866        if let Some(tab) = self.tab_manager.get_tab_mut(tab_id) {
867            let width_px = (renderer_cols as f32 * cell_width) as usize;
868            let height_px = (renderer_rows as f32 * cell_height) as usize;
869
870            if let Ok(mut term) = tab.terminal.try_lock() {
871                term.set_cell_dimensions(cell_width as u32, cell_height as u32);
872                // Send resize to ensure PTY has correct pixel dimensions
873                let _ = term.resize_with_pixels(renderer_cols, renderer_rows, width_px, height_px);
874                log::info!(
875                    "Initial terminal dimensions: {}x{} ({}x{} px)",
876                    renderer_cols,
877                    renderer_rows,
878                    width_px,
879                    height_px
880                );
881            }
882
883            // Start refresh task for the first tab
884            tab.start_refresh_task(
885                Arc::clone(&self.runtime),
886                Arc::clone(&window),
887                self.config.max_fps,
888            );
889        }
890
891        // Auto-connect agent if panel is open on startup and auto-launch is enabled
892        if self.ai_inspector.open {
893            self.try_auto_connect_agent();
894        }
895
896        // Check if we should prompt user to install integrations (shaders and/or shell integration)
897        if self.config.should_prompt_integrations() {
898            log::info!("Integrations not installed - showing welcome dialog");
899            self.integrations_ui.show_dialog();
900            self.needs_redraw = true;
901            window.request_redraw();
902        }
903
904        Ok(())
905    }
906
907    /// Force surface reconfiguration - useful when rendering becomes corrupted
908    /// after moving between monitors or when automatic detection fails.
909    /// Also clears glyph cache to ensure fonts render correctly.
910    pub(crate) fn force_surface_reconfigure(&mut self) {
911        log::info!("Force surface reconfigure triggered");
912
913        if let Some(renderer) = &mut self.renderer {
914            // Reconfigure the surface
915            renderer.reconfigure_surface();
916
917            // Clear glyph cache to force re-rasterization at correct DPI
918            renderer.clear_glyph_cache();
919
920            // Invalidate cached cells to force full re-render
921            if let Some(tab) = self.tab_manager.active_tab_mut() {
922                tab.cache.cells = None;
923            }
924        }
925
926        // On macOS, reconfigure the Metal layer
927        #[cfg(target_os = "macos")]
928        {
929            if let Some(window) = &self.window
930                && let Err(e) = crate::macos_metal::configure_metal_layer_for_performance(window)
931            {
932                log::warn!("Failed to reconfigure Metal layer: {}", e);
933            }
934        }
935
936        // Request redraw
937        self.needs_redraw = true;
938        self.request_redraw();
939    }
940
941    // ========================================================================
942    // Tab Bar Offsets
943    // ========================================================================
944
945    /// Apply tab bar offsets based on the current position configuration.
946    /// Sets content_offset_y (top), content_offset_x (left), and content_inset_bottom (bottom).
947    /// Returns Some((cols, rows)) if any offset changed and caused a grid resize.
948    pub(crate) fn apply_tab_bar_offsets(
949        &self,
950        renderer: &mut crate::renderer::Renderer,
951        tab_bar_height: f32,
952        tab_bar_width: f32,
953    ) -> Option<(usize, usize)> {
954        Self::apply_tab_bar_offsets_for_position(
955            self.config.tab_bar_position,
956            renderer,
957            tab_bar_height,
958            tab_bar_width,
959        )
960    }
961
962    /// Static helper to apply tab bar offsets (avoids borrowing self).
963    pub(crate) fn apply_tab_bar_offsets_for_position(
964        position: crate::config::TabBarPosition,
965        renderer: &mut crate::renderer::Renderer,
966        tab_bar_height: f32,
967        tab_bar_width: f32,
968    ) -> Option<(usize, usize)> {
969        use crate::config::TabBarPosition;
970        let (offset_y, offset_x, inset_bottom) = match position {
971            TabBarPosition::Top => (tab_bar_height, 0.0, 0.0),
972            TabBarPosition::Bottom => (0.0, 0.0, tab_bar_height),
973            TabBarPosition::Left => (0.0, tab_bar_width, 0.0),
974        };
975
976        let mut result = None;
977        if let Some(grid) = renderer.set_content_offset_y(offset_y) {
978            result = Some(grid);
979        }
980        if let Some(grid) = renderer.set_content_offset_x(offset_x) {
981            result = Some(grid);
982        }
983        if let Some(grid) = renderer.set_content_inset_bottom(inset_bottom) {
984            result = Some(grid);
985        }
986        result
987    }
988
989    // AI Inspector Panel Width Sync
990    // ========================================================================
991
992    /// Sync the AI Inspector panel consumed width with the renderer.
993    ///
994    /// When the panel opens, closes, or is resized by dragging, the terminal
995    /// column count must be updated so text reflows to fit the available space.
996    /// This method checks whether the consumed width has changed and, if so,
997    /// updates the renderer's right content inset and resizes all terminals.
998    pub(crate) fn sync_ai_inspector_width(&mut self) {
999        let current_width = self.ai_inspector.consumed_width();
1000
1001        if let Some(renderer) = &mut self.renderer {
1002            // Always verify the renderer's content_inset_right matches the expected
1003            // physical value. This catches cases where content_inset_right was reset
1004            // (e.g., renderer rebuild, scale factor change) even when the logical
1005            // panel width hasn't changed.
1006            // The renderer's set_content_inset_right() has its own guard that only
1007            // triggers a resize when the physical value actually differs.
1008            if let Some((new_cols, new_rows)) = renderer.set_content_inset_right(current_width) {
1009                let cell_width = renderer.cell_width();
1010                let cell_height = renderer.cell_height();
1011                let width_px = (new_cols as f32 * cell_width) as usize;
1012                let height_px = (new_rows as f32 * cell_height) as usize;
1013
1014                for tab in self.tab_manager.tabs_mut() {
1015                    if let Ok(mut term) = tab.terminal.try_lock() {
1016                        term.set_cell_dimensions(cell_width as u32, cell_height as u32);
1017                        let _ = term.resize_with_pixels(new_cols, new_rows, width_px, height_px);
1018                    }
1019                    tab.cache.cells = None;
1020                }
1021
1022                crate::debug_info!(
1023                    "AI_INSPECTOR",
1024                    "Panel width synced to {:.0}px, resized terminals to {}x{}",
1025                    current_width,
1026                    new_cols,
1027                    new_rows
1028                );
1029                self.needs_redraw = true;
1030            } else if (current_width - self.last_inspector_width).abs() >= 1.0 {
1031                // Logical width changed but physical grid didn't resize
1032                // (could happen with very small changes below cell width threshold)
1033                self.needs_redraw = true;
1034            }
1035        }
1036
1037        // Persist panel width to config when the user finishes resizing.
1038        if !self.ai_inspector.is_resizing()
1039            && (current_width - self.last_inspector_width).abs() >= 1.0
1040            && current_width > 0.0
1041            && self.ai_inspector.open
1042        {
1043            self.config.ai_inspector_width = self.ai_inspector.width;
1044            // Save to disk so the width is remembered across sessions.
1045            if let Err(e) = self.config.save() {
1046                log::error!("Failed to save AI inspector width: {}", e);
1047            }
1048        }
1049
1050        self.last_inspector_width = current_width;
1051    }
1052
1053    /// Connect to an ACP agent by identity string.
1054    ///
1055    /// This extracts the agent connection logic so it can be called both from
1056    /// `InspectorAction::ConnectAgent` and from the auto-connect-on-open path.
1057    pub(crate) fn connect_agent(&mut self, identity: &str) {
1058        if let Some(agent_config) = self
1059            .available_agents
1060            .iter()
1061            .find(|a| a.identity == identity)
1062        {
1063            // Clean up any previous agent before starting a new connection.
1064            if let Some(old_agent) = self.agent.take() {
1065                let runtime = self.runtime.clone();
1066                runtime.spawn(async move {
1067                    let mut agent = old_agent.lock().await;
1068                    agent.disconnect().await;
1069                });
1070            }
1071            self.agent_rx = None;
1072            self.agent_tx = None;
1073            self.agent_client = None;
1074
1075            let (tx, rx) = mpsc::unbounded_channel();
1076            self.agent_rx = Some(rx);
1077            self.agent_tx = Some(tx.clone());
1078            let ui_tx = tx.clone();
1079            let safe_paths = SafePaths {
1080                config_dir: Config::config_dir(),
1081                shaders_dir: Config::shaders_dir(),
1082            };
1083            let mcp_server_bin =
1084                std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("par-term"));
1085            let agent = Agent::new(agent_config.clone(), tx, safe_paths, mcp_server_bin);
1086            agent.auto_approve.store(
1087                self.config.ai_inspector_auto_approve,
1088                std::sync::atomic::Ordering::Relaxed,
1089            );
1090            let agent = Arc::new(tokio::sync::Mutex::new(agent));
1091            self.agent = Some(agent.clone());
1092
1093            // Determine CWD for the agent session
1094            let fallback_cwd = std::env::current_dir()
1095                .unwrap_or_default()
1096                .to_string_lossy()
1097                .to_string();
1098            let cwd = if let Some(tab) = self.tab_manager.active_tab() {
1099                if let Ok(term) = tab.terminal.try_lock() {
1100                    term.shell_integration_cwd()
1101                        .unwrap_or_else(|| fallback_cwd.clone())
1102                } else {
1103                    fallback_cwd.clone()
1104                }
1105            } else {
1106                fallback_cwd
1107            };
1108
1109            let capabilities = ClientCapabilities {
1110                fs: FsCapabilities {
1111                    read_text_file: true,
1112                    write_text_file: true,
1113                    list_directory: true,
1114                    find: true,
1115                },
1116                terminal: self.config.ai_inspector_agent_terminal_access,
1117                config: true,
1118            };
1119
1120            let auto_approve = self.config.ai_inspector_auto_approve;
1121            let runtime = self.runtime.clone();
1122            runtime.spawn(async move {
1123                let mut agent = agent.lock().await;
1124                if let Err(e) = agent.connect(&cwd, capabilities).await {
1125                    log::error!("ACP: failed to connect to agent: {e}");
1126                    return;
1127                }
1128                if let Some(client) = &agent.client {
1129                    let _ = ui_tx.send(AgentMessage::ClientReady(Arc::clone(client)));
1130                }
1131                if auto_approve && let Err(e) = agent.set_mode("bypassPermissions").await {
1132                    log::error!("ACP: failed to set bypassPermissions mode: {e}");
1133                }
1134            });
1135        }
1136    }
1137
1138    /// Auto-connect to the configured agent if auto-launch is enabled and no agent is connected.
1139    pub(crate) fn try_auto_connect_agent(&mut self) {
1140        if self.config.ai_inspector_auto_launch
1141            && self.ai_inspector.agent_status == AgentStatus::Disconnected
1142            && self.agent.is_none()
1143        {
1144            let identity = self.config.ai_inspector_agent.clone();
1145            if !identity.is_empty() {
1146                log::info!("ACP: auto-connecting to agent '{}'", identity);
1147                self.connect_agent(&identity);
1148            }
1149        }
1150    }
1151
1152    // Status Bar Inset Sync
1153    // ========================================================================
1154
1155    /// Sync the status bar bottom inset with the renderer so that the terminal
1156    /// grid does not extend behind the status bar.
1157    ///
1158    /// Must be called before cells are gathered each frame so the grid size
1159    /// is correct. Only triggers a terminal resize when the height changes
1160    /// (e.g., status bar toggled on/off or height changed in settings).
1161    pub(crate) fn sync_status_bar_inset(&mut self) {
1162        let is_tmux = self.is_tmux_connected();
1163        let tmux_bar = crate::tmux_status_bar_ui::TmuxStatusBarUI::height(&self.config, is_tmux);
1164        let custom_bar = self.status_bar_ui.height(&self.config, self.is_fullscreen);
1165        let total = tmux_bar + custom_bar;
1166
1167        if let Some(renderer) = &mut self.renderer
1168            && let Some((new_cols, new_rows)) = renderer.set_egui_bottom_inset(total)
1169        {
1170            let cell_width = renderer.cell_width();
1171            let cell_height = renderer.cell_height();
1172            let width_px = (new_cols as f32 * cell_width) as usize;
1173            let height_px = (new_rows as f32 * cell_height) as usize;
1174
1175            for tab in self.tab_manager.tabs_mut() {
1176                if let Ok(mut term) = tab.terminal.try_lock() {
1177                    term.set_cell_dimensions(cell_width as u32, cell_height as u32);
1178                    let _ = term.resize_with_pixels(new_cols, new_rows, width_px, height_px);
1179                }
1180                tab.cache.cells = None;
1181            }
1182        }
1183    }
1184
1185    // Shader Hot Reload
1186    // ========================================================================
1187
1188    /// Initialize the shader watcher for hot reload support
1189    pub(crate) fn init_shader_watcher(&mut self) {
1190        debug_info!(
1191            "SHADER",
1192            "init_shader_watcher: hot_reload={}",
1193            self.config.shader_hot_reload
1194        );
1195
1196        if !self.config.shader_hot_reload {
1197            log::debug!("Shader hot reload disabled");
1198            return;
1199        }
1200
1201        let background_path = self
1202            .config
1203            .custom_shader
1204            .as_ref()
1205            .filter(|_| self.config.custom_shader_enabled)
1206            .map(|s| Config::shader_path(s));
1207
1208        let cursor_path = self
1209            .config
1210            .cursor_shader
1211            .as_ref()
1212            .filter(|_| self.config.cursor_shader_enabled)
1213            .map(|s| Config::shader_path(s));
1214
1215        debug_info!(
1216            "SHADER",
1217            "Shader paths: background={:?}, cursor={:?}",
1218            background_path,
1219            cursor_path
1220        );
1221
1222        if background_path.is_none() && cursor_path.is_none() {
1223            debug_info!("SHADER", "No shaders to watch for hot reload");
1224            return;
1225        }
1226
1227        match ShaderWatcher::new(
1228            background_path.as_deref(),
1229            cursor_path.as_deref(),
1230            self.config.shader_hot_reload_delay,
1231        ) {
1232            Ok(watcher) => {
1233                debug_info!(
1234                    "SHADER",
1235                    "Shader hot reload initialized (debounce: {}ms)",
1236                    self.config.shader_hot_reload_delay
1237                );
1238                self.shader_watcher = Some(watcher);
1239            }
1240            Err(e) => {
1241                debug_info!("SHADER", "Failed to initialize shader hot reload: {}", e);
1242            }
1243        }
1244    }
1245
1246    /// Reinitialize shader watcher when shader paths change
1247    pub(crate) fn reinit_shader_watcher(&mut self) {
1248        debug_info!(
1249            "SHADER",
1250            "reinit_shader_watcher CALLED: shader={:?}, cursor={:?}",
1251            self.config.custom_shader,
1252            self.config.cursor_shader
1253        );
1254        // Drop existing watcher
1255        self.shader_watcher = None;
1256        self.shader_reload_error = None;
1257
1258        // Reinitialize if hot reload is still enabled
1259        self.init_shader_watcher();
1260    }
1261
1262    /// Initialize the config file watcher for automatic reload.
1263    ///
1264    /// Watches `config.yaml` for changes so that when an ACP agent modifies
1265    /// the config, par-term can auto-reload shader and other settings.
1266    pub(crate) fn init_config_watcher(&mut self) {
1267        let config_path = Config::config_path();
1268        if !config_path.exists() {
1269            debug_info!("CONFIG", "Config file does not exist, skipping watcher");
1270            return;
1271        }
1272        match crate::config::watcher::ConfigWatcher::new(&config_path, 500) {
1273            Ok(watcher) => {
1274                debug_info!("CONFIG", "Config watcher initialized");
1275                self.config_watcher = Some(watcher);
1276            }
1277            Err(e) => {
1278                debug_info!("CONFIG", "Failed to initialize config watcher: {}", e);
1279            }
1280        }
1281    }
1282
1283    /// Initialize the watcher for `.config-update.json` (MCP server config updates).
1284    ///
1285    /// The MCP server (spawned by the ACP agent) writes config updates to this
1286    /// file. We watch it, apply the updates in-memory, and delete it.
1287    pub(crate) fn init_config_update_watcher(&mut self) {
1288        let update_path = Config::config_dir().join(".config-update.json");
1289
1290        // Create the file if it doesn't exist so the watcher can start
1291        if !update_path.exists() {
1292            if let Some(parent) = update_path.parent() {
1293                let _ = std::fs::create_dir_all(parent);
1294            }
1295            let _ = std::fs::write(&update_path, "");
1296        }
1297
1298        match crate::config::watcher::ConfigWatcher::new(&update_path, 200) {
1299            Ok(watcher) => {
1300                debug_info!("CONFIG", "Config-update watcher initialized");
1301                self.config_update_watcher = Some(watcher);
1302            }
1303            Err(e) => {
1304                debug_info!(
1305                    "CONFIG",
1306                    "Failed to initialize config-update watcher: {}",
1307                    e
1308                );
1309            }
1310        }
1311    }
1312
1313    /// Check for pending config update file changes (from MCP server).
1314    ///
1315    /// When the MCP server writes `.config-update.json`, this reads it,
1316    /// applies the updates in-memory, saves to disk, and removes the file.
1317    pub(crate) fn check_config_update_file(&mut self) {
1318        let Some(watcher) = &self.config_update_watcher else {
1319            return;
1320        };
1321        if watcher.try_recv().is_none() {
1322            return;
1323        }
1324
1325        let update_path = Config::config_dir().join(".config-update.json");
1326        let content = match std::fs::read_to_string(&update_path) {
1327            Ok(c) if c.trim().is_empty() => return,
1328            Ok(c) => c,
1329            Err(e) => {
1330                log::warn!("CONFIG: failed to read config-update file: {e}");
1331                return;
1332            }
1333        };
1334
1335        match serde_json::from_str::<std::collections::HashMap<String, serde_json::Value>>(&content)
1336        {
1337            Ok(updates) => {
1338                log::info!(
1339                    "CONFIG: applying MCP config update ({} keys): {:?}",
1340                    updates.len(),
1341                    updates
1342                );
1343                if let Err(e) = self.apply_agent_config_updates(&updates) {
1344                    log::error!("CONFIG: MCP config update failed: {e}");
1345                } else {
1346                    self.config_changed_by_agent = true;
1347                }
1348                self.needs_redraw = true;
1349            }
1350            Err(e) => {
1351                log::error!("CONFIG: invalid JSON in config-update file: {e}");
1352            }
1353        }
1354
1355        // Clear the file so we don't re-process it
1356        let _ = std::fs::write(&update_path, "");
1357    }
1358
1359    /// Check for pending config file changes and apply them.
1360    ///
1361    /// Called periodically from the event loop. On config change:
1362    /// 1. Reloads config from disk
1363    /// 2. Applies shader-related config changes
1364    /// 3. Reinitializes shader watcher if shader paths changed
1365    pub(crate) fn check_config_reload(&mut self) {
1366        let Some(watcher) = &self.config_watcher else {
1367            return;
1368        };
1369        let Some(_event) = watcher.try_recv() else {
1370            return;
1371        };
1372
1373        log::info!("CONFIG: config file changed, reloading...");
1374
1375        match Config::load() {
1376            Ok(new_config) => {
1377                use crate::app::config_updates::ConfigChanges;
1378
1379                let changes = ConfigChanges::detect(&self.config, &new_config);
1380
1381                // Replace the entire in-memory config so that any subsequent
1382                // config.save() writes the agent's changes, not stale values.
1383                self.config = new_config;
1384
1385                log::info!(
1386                    "CONFIG: shader_changed={} cursor_changed={} shader={:?}",
1387                    changes.any_shader_change(),
1388                    changes.any_cursor_shader_toggle(),
1389                    self.config.custom_shader
1390                );
1391
1392                // Apply shader changes to the renderer
1393                if let Some(renderer) = &mut self.renderer {
1394                    if changes.any_shader_change() || changes.shader_per_shader_config {
1395                        log::info!("CONFIG: applying background shader change to renderer");
1396                        let shader_override = self
1397                            .config
1398                            .custom_shader
1399                            .as_ref()
1400                            .and_then(|name| self.config.shader_configs.get(name));
1401                        let metadata = self
1402                            .config
1403                            .custom_shader
1404                            .as_ref()
1405                            .and_then(|name| self.shader_metadata_cache.get(name).cloned());
1406                        let resolved = crate::config::shader_config::resolve_shader_config(
1407                            shader_override,
1408                            metadata.as_ref(),
1409                            &self.config,
1410                        );
1411                        if let Err(e) = renderer.set_custom_shader_enabled(
1412                            self.config.custom_shader_enabled,
1413                            self.config.custom_shader.as_deref(),
1414                            self.config.window_opacity,
1415                            self.config.custom_shader_animation,
1416                            resolved.animation_speed,
1417                            resolved.full_content,
1418                            resolved.brightness,
1419                            &resolved.channel_paths(),
1420                            resolved.cubemap_path().map(|p| p.as_path()),
1421                        ) {
1422                            log::error!("Config reload: shader load failed: {e}");
1423                        }
1424                    }
1425                    if changes.any_cursor_shader_toggle() {
1426                        log::info!("CONFIG: applying cursor shader change to renderer");
1427                        if let Err(e) = renderer.set_cursor_shader_enabled(
1428                            self.config.cursor_shader_enabled,
1429                            self.config.cursor_shader.as_deref(),
1430                            self.config.window_opacity,
1431                            self.config.cursor_shader_animation,
1432                            self.config.cursor_shader_animation_speed,
1433                        ) {
1434                            log::error!("Config reload: cursor shader load failed: {e}");
1435                        }
1436                    }
1437                }
1438
1439                // Reinit shader watcher if paths changed
1440                if changes.needs_watcher_reinit() {
1441                    self.reinit_shader_watcher();
1442                }
1443
1444                self.needs_redraw = true;
1445                debug_info!("CONFIG", "Config reloaded successfully");
1446            }
1447            Err(e) => {
1448                log::error!("Failed to reload config: {}", e);
1449            }
1450        }
1451    }
1452
1453    /// Apply config updates from the ACP agent.
1454    ///
1455    /// Updates the in-memory config, applies changes to the renderer, and
1456    /// saves to disk. Returns `Ok(())` on success or an error string.
1457    fn apply_agent_config_updates(
1458        &mut self,
1459        updates: &std::collections::HashMap<String, serde_json::Value>,
1460    ) -> Result<(), String> {
1461        let mut errors = Vec::new();
1462        let old_config = self.config.clone();
1463
1464        for (key, value) in updates {
1465            if let Err(e) = self.apply_single_config_update(key, value) {
1466                errors.push(format!("{key}: {e}"));
1467            }
1468        }
1469
1470        if !errors.is_empty() {
1471            return Err(errors.join("; "));
1472        }
1473
1474        // Detect changes and apply to renderer
1475        use crate::app::config_updates::ConfigChanges;
1476        let changes = ConfigChanges::detect(&old_config, &self.config);
1477
1478        log::info!(
1479            "ACP config/update: shader_change={} cursor_change={} old_shader={:?} new_shader={:?}",
1480            changes.any_shader_change(),
1481            changes.any_cursor_shader_toggle(),
1482            old_config.custom_shader,
1483            self.config.custom_shader
1484        );
1485
1486        if let Some(renderer) = &mut self.renderer {
1487            if changes.any_shader_change() || changes.shader_per_shader_config {
1488                log::info!("ACP config/update: applying background shader change to renderer");
1489                let shader_override = self
1490                    .config
1491                    .custom_shader
1492                    .as_ref()
1493                    .and_then(|name| self.config.shader_configs.get(name));
1494                let metadata = self
1495                    .config
1496                    .custom_shader
1497                    .as_ref()
1498                    .and_then(|name| self.shader_metadata_cache.get(name).cloned());
1499                let resolved = crate::config::shader_config::resolve_shader_config(
1500                    shader_override,
1501                    metadata.as_ref(),
1502                    &self.config,
1503                );
1504                if let Err(e) = renderer.set_custom_shader_enabled(
1505                    self.config.custom_shader_enabled,
1506                    self.config.custom_shader.as_deref(),
1507                    self.config.window_opacity,
1508                    self.config.custom_shader_animation,
1509                    resolved.animation_speed,
1510                    resolved.full_content,
1511                    resolved.brightness,
1512                    &resolved.channel_paths(),
1513                    resolved.cubemap_path().map(|p| p.as_path()),
1514                ) {
1515                    log::error!("ACP config/update: shader load failed: {e}");
1516                }
1517            }
1518            if changes.any_cursor_shader_toggle() {
1519                log::info!("ACP config/update: applying cursor shader change to renderer");
1520                if let Err(e) = renderer.set_cursor_shader_enabled(
1521                    self.config.cursor_shader_enabled,
1522                    self.config.cursor_shader.as_deref(),
1523                    self.config.window_opacity,
1524                    self.config.cursor_shader_animation,
1525                    self.config.cursor_shader_animation_speed,
1526                ) {
1527                    log::error!("ACP config/update: cursor shader load failed: {e}");
1528                }
1529            }
1530        }
1531
1532        if changes.needs_watcher_reinit() {
1533            self.reinit_shader_watcher();
1534        }
1535
1536        // Save to disk
1537        if let Err(e) = self.config.save() {
1538            return Err(format!("Failed to save config: {e}"));
1539        }
1540
1541        Ok(())
1542    }
1543
1544    /// Apply a single config key/value update to the in-memory config.
1545    fn apply_single_config_update(
1546        &mut self,
1547        key: &str,
1548        value: &serde_json::Value,
1549    ) -> Result<(), String> {
1550        match key {
1551            // -- Background shader --
1552            "custom_shader" => {
1553                self.config.custom_shader = if value.is_null() {
1554                    None
1555                } else {
1556                    Some(value.as_str().ok_or("expected string or null")?.to_string())
1557                };
1558                Ok(())
1559            }
1560            "custom_shader_enabled" => {
1561                self.config.custom_shader_enabled = value.as_bool().ok_or("expected boolean")?;
1562                Ok(())
1563            }
1564            "custom_shader_animation" => {
1565                self.config.custom_shader_animation = value.as_bool().ok_or("expected boolean")?;
1566                Ok(())
1567            }
1568            "custom_shader_animation_speed" => {
1569                self.config.custom_shader_animation_speed = json_as_f32(value)?;
1570                Ok(())
1571            }
1572            "custom_shader_brightness" => {
1573                self.config.custom_shader_brightness = json_as_f32(value)?;
1574                Ok(())
1575            }
1576            "custom_shader_text_opacity" => {
1577                self.config.custom_shader_text_opacity = json_as_f32(value)?;
1578                Ok(())
1579            }
1580            "custom_shader_full_content" => {
1581                self.config.custom_shader_full_content =
1582                    value.as_bool().ok_or("expected boolean")?;
1583                Ok(())
1584            }
1585
1586            // -- Cursor shader --
1587            "cursor_shader" => {
1588                self.config.cursor_shader = if value.is_null() {
1589                    None
1590                } else {
1591                    Some(value.as_str().ok_or("expected string or null")?.to_string())
1592                };
1593                Ok(())
1594            }
1595            "cursor_shader_enabled" => {
1596                self.config.cursor_shader_enabled = value.as_bool().ok_or("expected boolean")?;
1597                Ok(())
1598            }
1599            "cursor_shader_animation" => {
1600                self.config.cursor_shader_animation = value.as_bool().ok_or("expected boolean")?;
1601                Ok(())
1602            }
1603            "cursor_shader_animation_speed" => {
1604                self.config.cursor_shader_animation_speed = json_as_f32(value)?;
1605                Ok(())
1606            }
1607            "cursor_shader_glow_radius" => {
1608                self.config.cursor_shader_glow_radius = json_as_f32(value)?;
1609                Ok(())
1610            }
1611            "cursor_shader_glow_intensity" => {
1612                self.config.cursor_shader_glow_intensity = json_as_f32(value)?;
1613                Ok(())
1614            }
1615            "cursor_shader_trail_duration" => {
1616                self.config.cursor_shader_trail_duration = json_as_f32(value)?;
1617                Ok(())
1618            }
1619            "cursor_shader_hides_cursor" => {
1620                self.config.cursor_shader_hides_cursor =
1621                    value.as_bool().ok_or("expected boolean")?;
1622                Ok(())
1623            }
1624
1625            // -- Window --
1626            "window_opacity" => {
1627                self.config.window_opacity = json_as_f32(value)?;
1628                Ok(())
1629            }
1630            "font_size" => {
1631                self.config.font_size = json_as_f32(value)?;
1632                Ok(())
1633            }
1634
1635            _ => Err(format!("unknown or read-only config key: {key}")),
1636        }
1637    }
1638
1639    /// Check anti-idle timers and send keep-alive codes when due.
1640    ///
1641    /// Returns the next Instant when anti-idle should run, or None if disabled.
1642    pub(crate) fn handle_anti_idle(
1643        &mut self,
1644        now: std::time::Instant,
1645    ) -> Option<std::time::Instant> {
1646        if !self.config.anti_idle_enabled {
1647            return None;
1648        }
1649
1650        let idle_threshold = std::time::Duration::from_secs(self.config.anti_idle_seconds.max(1));
1651        let keep_alive_code = [self.config.anti_idle_code];
1652        let mut next_due: Option<std::time::Instant> = None;
1653
1654        for tab in self.tab_manager.tabs_mut() {
1655            if let Ok(term) = tab.terminal.try_lock() {
1656                // Treat new terminal output as activity
1657                let current_generation = term.update_generation();
1658                if current_generation > tab.anti_idle_last_generation {
1659                    tab.anti_idle_last_generation = current_generation;
1660                    tab.anti_idle_last_activity = now;
1661                }
1662
1663                // If idle long enough, send keep-alive code
1664                if should_send_keep_alive(tab.anti_idle_last_activity, now, idle_threshold) {
1665                    if let Err(e) = term.write(&keep_alive_code) {
1666                        log::warn!(
1667                            "Failed to send anti-idle keep-alive for tab {}: {}",
1668                            tab.id,
1669                            e
1670                        );
1671                    } else {
1672                        tab.anti_idle_last_activity = now;
1673                    }
1674                }
1675
1676                // Compute next due time for this tab
1677                let elapsed = now.duration_since(tab.anti_idle_last_activity);
1678                let remaining = if elapsed >= idle_threshold {
1679                    idle_threshold
1680                } else {
1681                    idle_threshold - elapsed
1682                };
1683                let candidate = now + remaining;
1684                next_due = Some(next_due.map_or(candidate, |prev| prev.min(candidate)));
1685            }
1686        }
1687
1688        next_due
1689    }
1690
1691    /// Check for and handle shader reload events
1692    ///
1693    /// Should be called periodically (e.g., in about_to_wait or render loop).
1694    /// Returns true if a shader was reloaded.
1695    pub(crate) fn check_shader_reload(&mut self) -> bool {
1696        let Some(watcher) = &self.shader_watcher else {
1697            return false;
1698        };
1699
1700        let Some(event) = watcher.try_recv() else {
1701            return false;
1702        };
1703
1704        self.handle_shader_reload_event(event)
1705    }
1706
1707    /// Handle a shader reload event
1708    ///
1709    /// On success: clears errors, triggers redraw, optionally shows notification
1710    /// On failure: preserves the old working shader, logs error, shows notification
1711    fn handle_shader_reload_event(&mut self, event: ShaderReloadEvent) -> bool {
1712        let shader_name = match event.shader_type {
1713            ShaderType::Background => "Background shader",
1714            ShaderType::Cursor => "Cursor shader",
1715        };
1716        let file_name = event
1717            .path
1718            .file_name()
1719            .and_then(|n| n.to_str())
1720            .unwrap_or("shader");
1721
1722        log::info!("Hot reload: {} from {}", shader_name, event.path.display());
1723
1724        // Read the shader source
1725        let source = match std::fs::read_to_string(&event.path) {
1726            Ok(s) => s,
1727            Err(e) => {
1728                let error_msg = format!("Cannot read '{}': {}", file_name, e);
1729                log::error!("Shader hot reload failed: {}", error_msg);
1730                self.shader_reload_error = Some(error_msg.clone());
1731                // Track error for standalone settings window propagation
1732                match event.shader_type {
1733                    ShaderType::Background => {
1734                        self.background_shader_reload_result = Some(Some(error_msg.clone()));
1735                    }
1736                    ShaderType::Cursor => {
1737                        self.cursor_shader_reload_result = Some(Some(error_msg.clone()));
1738                    }
1739                }
1740                // Notify user of the error
1741                self.deliver_notification(
1742                    "Shader Reload Failed",
1743                    &format!("{} - {}", shader_name, error_msg),
1744                );
1745                // Trigger visual bell if enabled to alert user
1746                if self.config.notification_bell_visual
1747                    && let Some(tab) = self.tab_manager.active_tab_mut()
1748                {
1749                    tab.bell.visual_flash = Some(std::time::Instant::now());
1750                }
1751                return false;
1752            }
1753        };
1754
1755        let Some(renderer) = &mut self.renderer else {
1756            log::error!("Cannot reload shader: no renderer available");
1757            return false;
1758        };
1759
1760        // Attempt to reload the shader
1761        // Note: On compilation failure, the old shader pipeline is preserved
1762        let result = match event.shader_type {
1763            ShaderType::Background => renderer.reload_shader_from_source(&source),
1764            ShaderType::Cursor => renderer.reload_cursor_shader_from_source(&source),
1765        };
1766
1767        match result {
1768            Ok(()) => {
1769                log::info!("{} reloaded successfully from {}", shader_name, file_name);
1770                self.shader_reload_error = None;
1771                // Track success for standalone settings window propagation
1772                match event.shader_type {
1773                    ShaderType::Background => {
1774                        self.background_shader_reload_result = Some(None);
1775                    }
1776                    ShaderType::Cursor => {
1777                        self.cursor_shader_reload_result = Some(None);
1778                    }
1779                }
1780                self.needs_redraw = true;
1781                self.request_redraw();
1782                true
1783            }
1784            Err(e) => {
1785                // Extract the most relevant error message from the chain
1786                let root_cause = e.root_cause().to_string();
1787                let error_msg = if root_cause.len() > 200 {
1788                    // Truncate very long error messages
1789                    format!("{}...", &root_cause[..200])
1790                } else {
1791                    root_cause
1792                };
1793
1794                log::error!(
1795                    "{} compilation failed (old shader preserved): {}",
1796                    shader_name,
1797                    error_msg
1798                );
1799                log::debug!("Full error chain: {:#}", e);
1800
1801                self.shader_reload_error = Some(error_msg.clone());
1802                // Track error for standalone settings window propagation
1803                match event.shader_type {
1804                    ShaderType::Background => {
1805                        self.background_shader_reload_result = Some(Some(error_msg.clone()));
1806                    }
1807                    ShaderType::Cursor => {
1808                        self.cursor_shader_reload_result = Some(Some(error_msg.clone()));
1809                    }
1810                }
1811
1812                // Notify user of the compilation error
1813                self.deliver_notification(
1814                    "Shader Compilation Error",
1815                    &format!("{}: {}", file_name, error_msg),
1816                );
1817
1818                // Trigger visual bell if enabled to alert user
1819                if self.config.notification_bell_visual
1820                    && let Some(tab) = self.tab_manager.active_tab_mut()
1821                {
1822                    tab.bell.visual_flash = Some(std::time::Instant::now());
1823                }
1824
1825                false
1826            }
1827        }
1828    }
1829
1830    /// Check if egui is currently using the pointer (mouse is over an egui UI element)
1831    pub(crate) fn is_egui_using_pointer(&self) -> bool {
1832        // AI Inspector resize handle uses direct pointer tracking (not egui widgets),
1833        // so egui doesn't know about it. Check explicitly to prevent mouse events
1834        // from reaching the terminal during resize drag or initial click on the handle.
1835        if self.ai_inspector.wants_pointer() {
1836            return true;
1837        }
1838        // Before first render, egui state is unreliable - allow mouse events through
1839        if !self.egui_initialized {
1840            return false;
1841        }
1842        // Always check egui context - the tab bar is always rendered via egui
1843        // and can consume pointer events (e.g., close button clicks)
1844        if let Some(ctx) = &self.egui_ctx {
1845            ctx.is_using_pointer() || ctx.wants_pointer_input()
1846        } else {
1847            false
1848        }
1849    }
1850
1851    /// Check if any egui overlay with text input is visible.
1852    /// Used to route clipboard operations (paste/copy/select-all) to egui
1853    /// instead of the terminal when a modal dialog is active.
1854    pub(crate) fn has_egui_overlay_visible(&self) -> bool {
1855        self.search_ui.visible
1856            || self.clipboard_history_ui.visible
1857            || self.command_history_ui.visible
1858            || self.shader_install_ui.visible
1859            || self.integrations_ui.visible
1860            || self.remote_shell_install_ui.is_visible()
1861            || self.quit_confirmation_ui.is_visible()
1862            || self.ssh_connect_ui.is_visible()
1863            || self.ai_inspector.open
1864    }
1865
1866    /// Check if egui is currently using keyboard input (e.g., text input or ComboBox has focus)
1867    pub(crate) fn is_egui_using_keyboard(&self) -> bool {
1868        // If any UI panel is visible, check if egui wants keyboard input
1869        // Note: Settings are handled by standalone SettingsWindow, not embedded UI
1870        // Note: Profile drawer does NOT block input - only modal dialogs do
1871        let any_ui_visible = self.help_ui.visible
1872            || self.clipboard_history_ui.visible
1873            || self.command_history_ui.visible
1874            || self.shader_install_ui.visible
1875            || self.integrations_ui.visible
1876            || self.remote_shell_install_ui.is_visible()
1877            || self.quit_confirmation_ui.is_visible()
1878            || self.ssh_connect_ui.is_visible()
1879            || self.ai_inspector.open;
1880        if !any_ui_visible {
1881            return false;
1882        }
1883
1884        // Check egui context for keyboard usage
1885        if let Some(ctx) = &self.egui_ctx {
1886            ctx.wants_keyboard_input()
1887        } else {
1888            false
1889        }
1890    }
1891
1892    /// Determine if scrollbar should be visible based on autohide setting and recent activity
1893    pub(crate) fn should_show_scrollbar(&self) -> bool {
1894        let tab = match self.tab_manager.active_tab() {
1895            Some(t) => t,
1896            None => return false,
1897        };
1898
1899        // No scrollbar needed if no scrollback available
1900        if tab.cache.scrollback_len == 0 {
1901            return false;
1902        }
1903
1904        // Always show when dragging or moving
1905        if tab.scroll_state.dragging {
1906            return true;
1907        }
1908
1909        // If autohide disabled, always show
1910        if self.config.scrollbar_autohide_delay == 0 {
1911            return true;
1912        }
1913
1914        // If scrolled away from bottom, keep visible
1915        if tab.scroll_state.offset > 0 || tab.scroll_state.target_offset > 0 {
1916            return true;
1917        }
1918
1919        // Show when pointer is near the scrollbar edge (hover reveal)
1920        if let Some(window) = &self.window {
1921            let padding = 32.0; // px hover band
1922            let width = window.inner_size().width as f64;
1923            let near_right = self.config.scrollbar_position != "left"
1924                && (width - tab.mouse.position.0) <= padding;
1925            let near_left =
1926                self.config.scrollbar_position == "left" && tab.mouse.position.0 <= padding;
1927            if near_left || near_right {
1928                return true;
1929            }
1930        }
1931
1932        // Otherwise, hide after delay
1933        tab.scroll_state.last_activity.elapsed().as_millis()
1934            < self.config.scrollbar_autohide_delay as u128
1935    }
1936
1937    /// Update cursor blink state based on configured interval and DECSCUSR style
1938    ///
1939    /// The cursor blink state is determined by:
1940    /// 1. If lock_cursor_style is enabled: use config.cursor_blink
1941    /// 2. If lock_cursor_blink is enabled and cursor_blink is false: force no blink
1942    /// 3. Otherwise: terminal's cursor style (set via DECSCUSR escape sequence)
1943    /// 4. Fallback: user's config setting (cursor_blink)
1944    ///
1945    /// DECSCUSR values: odd = blinking, even = steady
1946    /// - 0/1: Blinking block (default)
1947    /// - 2: Steady block
1948    /// - 3: Blinking underline
1949    /// - 4: Steady underline
1950    /// - 5: Blinking bar
1951    /// - 6: Steady bar
1952    pub(crate) fn update_cursor_blink(&mut self) {
1953        // If cursor style is locked, use the config's blink setting directly
1954        if self.config.lock_cursor_style {
1955            if !self.config.cursor_blink {
1956                self.cursor_opacity = (self.cursor_opacity + 0.1).min(1.0);
1957                return;
1958            }
1959        } else if self.config.lock_cursor_blink && !self.config.cursor_blink {
1960            // If blink is locked off, don't blink regardless of terminal style
1961            self.cursor_opacity = (self.cursor_opacity + 0.1).min(1.0);
1962            return;
1963        }
1964
1965        // Get cursor style from terminal to check if DECSCUSR specified blinking
1966        let cursor_should_blink = if self.config.lock_cursor_style {
1967            // Style is locked, use config's blink setting
1968            self.config.cursor_blink
1969        } else if let Some(tab) = self.tab_manager.active_tab()
1970            && let Ok(term) = tab.terminal.try_lock()
1971        {
1972            use par_term_emu_core_rust::cursor::CursorStyle;
1973            let style = term.cursor_style();
1974            // DECSCUSR: odd values (1,3,5) = blinking, even values (2,4,6) = steady
1975            matches!(
1976                style,
1977                CursorStyle::BlinkingBlock
1978                    | CursorStyle::BlinkingUnderline
1979                    | CursorStyle::BlinkingBar
1980            )
1981        } else {
1982            // Fallback to config setting if terminal lock unavailable
1983            self.config.cursor_blink
1984        };
1985
1986        if !cursor_should_blink {
1987            // Smoothly fade to full visibility if blinking disabled (by DECSCUSR or config)
1988            self.cursor_opacity = (self.cursor_opacity + 0.1).min(1.0);
1989            return;
1990        }
1991
1992        let now = std::time::Instant::now();
1993
1994        // If key was pressed recently (within 500ms), smoothly fade in cursor and reset blink timer
1995        if let Some(last_key) = self.last_key_press
1996            && now.duration_since(last_key).as_millis() < 500
1997        {
1998            self.cursor_opacity = (self.cursor_opacity + 0.1).min(1.0);
1999            self.last_cursor_blink = Some(now);
2000            return;
2001        }
2002
2003        // Smooth cursor blink animation using sine wave for natural fade
2004        let blink_interval = std::time::Duration::from_millis(self.config.cursor_blink_interval);
2005
2006        if let Some(last_blink) = self.last_cursor_blink {
2007            let elapsed = now.duration_since(last_blink);
2008            let progress = (elapsed.as_millis() as f32) / (blink_interval.as_millis() as f32);
2009
2010            // Use cosine wave for smooth fade in/out (starts at 1.0, fades to 0.0, back to 1.0)
2011            self.cursor_opacity = ((progress * std::f32::consts::PI).cos())
2012                .abs()
2013                .clamp(0.0, 1.0);
2014
2015            // Reset timer after full cycle (2x interval for full on+off)
2016            if elapsed >= blink_interval * 2 {
2017                self.last_cursor_blink = Some(now);
2018            }
2019        } else {
2020            // First time, start the blink timer with cursor fully visible
2021            self.cursor_opacity = 1.0;
2022            self.last_cursor_blink = Some(now);
2023        }
2024    }
2025
2026    /// Main render function for this window
2027    pub(crate) fn render(&mut self) {
2028        // Skip rendering if shutting down
2029        if self.is_shutting_down {
2030            return;
2031        }
2032
2033        // FPS throttling to enforce max_fps (focused) or unfocused_fps (unfocused)
2034        // This ensures rendering is capped even if VSync runs at a higher rate
2035        // or multiple sources are requesting redraws (refresh task, shader animations, etc.)
2036        let target_fps = if self.config.pause_refresh_on_blur && !self.is_focused {
2037            self.config.unfocused_fps
2038        } else {
2039            self.config.max_fps
2040        };
2041        let frame_interval_ms = 1000 / target_fps.max(1);
2042        let frame_interval = std::time::Duration::from_millis(frame_interval_ms as u64);
2043
2044        if let Some(last_render) = self.last_render_time {
2045            let elapsed = last_render.elapsed();
2046            if elapsed < frame_interval {
2047                // Not enough time has passed, skip this render
2048                return;
2049            }
2050        }
2051
2052        // Update last render time for FPS throttling
2053        self.last_render_time = Some(std::time::Instant::now());
2054
2055        let absolute_start = std::time::Instant::now();
2056
2057        // Reset redraw flag after rendering
2058        // This flag will be set again in about_to_wait if another redraw is needed
2059        self.needs_redraw = false;
2060
2061        // Track frame timing
2062        let frame_start = std::time::Instant::now();
2063
2064        // Calculate frame time from last render
2065        if let Some(last_start) = self.debug.last_frame_start {
2066            let frame_time = frame_start.duration_since(last_start);
2067            self.debug.frame_times.push(frame_time);
2068            if self.debug.frame_times.len() > 60 {
2069                self.debug.frame_times.remove(0);
2070            }
2071        }
2072        self.debug.last_frame_start = Some(frame_start);
2073
2074        // Update scroll animation
2075        let animation_running = if let Some(tab) = self.tab_manager.active_tab_mut() {
2076            tab.scroll_state.update_animation()
2077        } else {
2078            false
2079        };
2080
2081        // Update tab titles from terminal OSC sequences
2082        self.tab_manager.update_all_titles();
2083
2084        // Rebuild renderer if font-related settings changed
2085        if self.pending_font_rebuild {
2086            if let Err(e) = self.rebuild_renderer() {
2087                log::error!("Failed to rebuild renderer after font change: {}", e);
2088            }
2089            self.pending_font_rebuild = false;
2090        }
2091
2092        // Sync tab bar offsets with renderer's content offsets
2093        // This ensures the terminal grid correctly accounts for the tab bar position
2094        let tab_count = self.tab_manager.tab_count();
2095        let tab_bar_height = self.tab_bar_ui.get_height(tab_count, &self.config);
2096        let tab_bar_width = self.tab_bar_ui.get_width(tab_count, &self.config);
2097        crate::debug_trace!(
2098            "TAB_SYNC",
2099            "Tab count={}, tab_bar_height={:.0}, tab_bar_width={:.0}, position={:?}, mode={:?}",
2100            tab_count,
2101            tab_bar_height,
2102            tab_bar_width,
2103            self.config.tab_bar_position,
2104            self.config.tab_bar_mode
2105        );
2106        if let Some(renderer) = &mut self.renderer {
2107            let grid_changed = Self::apply_tab_bar_offsets_for_position(
2108                self.config.tab_bar_position,
2109                renderer,
2110                tab_bar_height,
2111                tab_bar_width,
2112            );
2113            if let Some((new_cols, new_rows)) = grid_changed {
2114                let cell_width = renderer.cell_width();
2115                let cell_height = renderer.cell_height();
2116                let width_px = (new_cols as f32 * cell_width) as usize;
2117                let height_px = (new_rows as f32 * cell_height) as usize;
2118
2119                for tab in self.tab_manager.tabs_mut() {
2120                    if let Ok(mut term) = tab.terminal.try_lock() {
2121                        term.set_cell_dimensions(cell_width as u32, cell_height as u32);
2122                        let _ = term.resize_with_pixels(new_cols, new_rows, width_px, height_px);
2123                    }
2124                    tab.cache.cells = None;
2125                }
2126                crate::debug_info!(
2127                    "TAB_SYNC",
2128                    "Tab bar offsets changed (position={:?}), resized terminals to {}x{}",
2129                    self.config.tab_bar_position,
2130                    new_cols,
2131                    new_rows
2132                );
2133            }
2134        }
2135
2136        // Sync status bar inset so the terminal grid does not extend behind it.
2137        // Must happen before cell gathering so the row count is correct.
2138        self.sync_status_bar_inset();
2139
2140        let (renderer_size, visible_lines, grid_cols) = if let Some(renderer) = &self.renderer {
2141            let (cols, rows) = renderer.grid_size();
2142            (renderer.size(), rows, cols)
2143        } else {
2144            return;
2145        };
2146
2147        // Get active tab's terminal and immediate state snapshots (avoid long borrows)
2148        let (
2149            terminal,
2150            scroll_offset,
2151            mouse_selection,
2152            cache_cells,
2153            cache_generation,
2154            cache_scroll_offset,
2155            cache_cursor_pos,
2156            cache_selection,
2157            cached_scrollback_len,
2158            cached_terminal_title,
2159            hovered_url,
2160        ) = match self.tab_manager.active_tab() {
2161            Some(t) => (
2162                t.terminal.clone(),
2163                t.scroll_state.offset,
2164                t.mouse.selection,
2165                t.cache.cells.clone(),
2166                t.cache.generation,
2167                t.cache.scroll_offset,
2168                t.cache.cursor_pos,
2169                t.cache.selection,
2170                t.cache.scrollback_len,
2171                t.cache.terminal_title.clone(),
2172                t.mouse.hovered_url.clone(),
2173            ),
2174            None => return,
2175        };
2176
2177        // Check if shell has exited
2178        let _is_running = if let Ok(term) = terminal.try_lock() {
2179            term.is_running()
2180        } else {
2181            true // Assume running if locked
2182        };
2183
2184        // Request another redraw if animation is still running
2185        if animation_running && let Some(window) = &self.window {
2186            window.request_redraw();
2187        }
2188
2189        // Get scroll offset and selection from active tab
2190
2191        // Get terminal cells for rendering (with dirty tracking optimization)
2192        // Also capture alt screen state to disable cursor shader for TUI apps
2193        let (cells, current_cursor_pos, cursor_style, is_alt_screen) = if let Ok(term) =
2194            terminal.try_lock()
2195        {
2196            // Get current generation to check if terminal content has changed
2197            let current_generation = term.update_generation();
2198
2199            // Normalize selection if it exists and extract mode
2200            let (selection, rectangular) = if let Some(sel) = mouse_selection {
2201                (
2202                    Some(sel.normalized()),
2203                    sel.mode == SelectionMode::Rectangular,
2204                )
2205            } else {
2206                (None, false)
2207            };
2208
2209            // Get cursor position and opacity (only show if we're at the bottom with no scroll offset
2210            // and the cursor is visible - TUI apps hide cursor via DECTCEM escape sequence)
2211            // If lock_cursor_visibility is enabled, ignore the terminal's visibility state
2212            // In copy mode, use the copy mode cursor position instead
2213            let cursor_visible = self.config.lock_cursor_visibility || term.is_cursor_visible();
2214            let current_cursor_pos = if self.copy_mode.active {
2215                self.copy_mode.screen_cursor_pos(scroll_offset)
2216            } else if scroll_offset == 0 && cursor_visible {
2217                Some(term.cursor_position())
2218            } else {
2219                None
2220            };
2221
2222            let cursor = current_cursor_pos.map(|pos| (pos, self.cursor_opacity));
2223
2224            // Get cursor style for geometric rendering
2225            // In copy mode, always use SteadyBlock for clear visibility
2226            // If lock_cursor_style is enabled, use the config's cursor style instead of terminal's
2227            // If lock_cursor_blink is enabled and cursor_blink is false, force steady cursor
2228            let cursor_style = if self.copy_mode.active && current_cursor_pos.is_some() {
2229                Some(TermCursorStyle::SteadyBlock)
2230            } else if current_cursor_pos.is_some() {
2231                if self.config.lock_cursor_style {
2232                    // Convert config cursor style to terminal cursor style
2233                    let style = if self.config.cursor_blink {
2234                        match self.config.cursor_style {
2235                            CursorStyle::Block => TermCursorStyle::BlinkingBlock,
2236                            CursorStyle::Beam => TermCursorStyle::BlinkingBar,
2237                            CursorStyle::Underline => TermCursorStyle::BlinkingUnderline,
2238                        }
2239                    } else {
2240                        match self.config.cursor_style {
2241                            CursorStyle::Block => TermCursorStyle::SteadyBlock,
2242                            CursorStyle::Beam => TermCursorStyle::SteadyBar,
2243                            CursorStyle::Underline => TermCursorStyle::SteadyUnderline,
2244                        }
2245                    };
2246                    Some(style)
2247                } else {
2248                    let mut style = term.cursor_style();
2249                    // If blink is locked off, convert blinking styles to steady
2250                    if self.config.lock_cursor_blink && !self.config.cursor_blink {
2251                        style = match style {
2252                            TermCursorStyle::BlinkingBlock => TermCursorStyle::SteadyBlock,
2253                            TermCursorStyle::BlinkingBar => TermCursorStyle::SteadyBar,
2254                            TermCursorStyle::BlinkingUnderline => TermCursorStyle::SteadyUnderline,
2255                            other => other,
2256                        };
2257                    }
2258                    Some(style)
2259                }
2260            } else {
2261                None
2262            };
2263
2264            log::trace!(
2265                "Cursor: pos={:?}, opacity={:.2}, style={:?}, scroll={}, visible={}",
2266                current_cursor_pos,
2267                self.cursor_opacity,
2268                cursor_style,
2269                scroll_offset,
2270                term.is_cursor_visible()
2271            );
2272
2273            // Check if we need to regenerate cells
2274            // Only regenerate when content actually changes, not on every cursor blink
2275            let needs_regeneration = cache_cells.is_none()
2276                || current_generation != cache_generation
2277                || scroll_offset != cache_scroll_offset
2278                || current_cursor_pos != cache_cursor_pos // Regenerate if cursor position changed
2279                || mouse_selection != cache_selection; // Regenerate if selection changed (including clearing)
2280
2281            let cell_gen_start = std::time::Instant::now();
2282            let (cells, is_cache_hit) = if needs_regeneration {
2283                // Generate fresh cells
2284                let fresh_cells =
2285                    term.get_cells_with_scrollback(scroll_offset, selection, rectangular, cursor);
2286
2287                (fresh_cells, false)
2288            } else {
2289                // Use cached cells - clone is still needed because of apply_url_underlines
2290                // but we track it accurately for debug logging
2291                (cache_cells.as_ref().unwrap().clone(), true)
2292            };
2293            self.debug.cache_hit = is_cache_hit;
2294            self.debug.cell_gen_time = cell_gen_start.elapsed();
2295
2296            // Check if alt screen is active (TUI apps like vim, htop)
2297            let is_alt_screen = term.is_alt_screen_active();
2298
2299            (cells, current_cursor_pos, cursor_style, is_alt_screen)
2300        } else if let Some(cached) = cache_cells {
2301            // Terminal locked (e.g., upload in progress), use cached cells so the
2302            // rest of the render pipeline (including file transfer overlay) can proceed.
2303            (cached, cache_cursor_pos, None, false)
2304        } else {
2305            return; // Terminal locked and no cache available, skip this frame
2306        };
2307
2308        // Ensure cursor visibility flag for cell renderer reflects current config every frame
2309        // (so toggling "Hide default cursor" takes effect immediately even if no other changes)
2310        // Resolve hides_cursor: per-shader override -> metadata defaults -> global config
2311        let resolved_hides_cursor = self
2312            .config
2313            .cursor_shader
2314            .as_ref()
2315            .and_then(|name| self.config.cursor_shader_configs.get(name))
2316            .and_then(|override_cfg| override_cfg.hides_cursor)
2317            .or_else(|| {
2318                self.config
2319                    .cursor_shader
2320                    .as_ref()
2321                    .and_then(|name| self.cursor_shader_metadata_cache.get(name))
2322                    .and_then(|meta| meta.defaults.hides_cursor)
2323            })
2324            .unwrap_or(self.config.cursor_shader_hides_cursor);
2325        // Resolve disable_in_alt_screen: per-shader override -> metadata defaults -> global config
2326        let resolved_disable_in_alt_screen = self
2327            .config
2328            .cursor_shader
2329            .as_ref()
2330            .and_then(|name| self.config.cursor_shader_configs.get(name))
2331            .and_then(|override_cfg| override_cfg.disable_in_alt_screen)
2332            .or_else(|| {
2333                self.config
2334                    .cursor_shader
2335                    .as_ref()
2336                    .and_then(|name| self.cursor_shader_metadata_cache.get(name))
2337                    .and_then(|meta| meta.defaults.disable_in_alt_screen)
2338            })
2339            .unwrap_or(self.config.cursor_shader_disable_in_alt_screen);
2340        let hide_cursor_for_shader = self.config.cursor_shader_enabled
2341            && resolved_hides_cursor
2342            && !(resolved_disable_in_alt_screen && is_alt_screen);
2343        if let Some(renderer) = &mut self.renderer {
2344            renderer.set_cursor_hidden_for_shader(hide_cursor_for_shader);
2345        }
2346
2347        // Update cache with regenerated cells (if needed)
2348        // Need to re-borrow as mutable after the terminal lock is released
2349        if !self.debug.cache_hit
2350            && let Some(tab) = self.tab_manager.active_tab_mut()
2351            && let Ok(term) = tab.terminal.try_lock()
2352        {
2353            let current_generation = term.update_generation();
2354            tab.cache.cells = Some(cells.clone());
2355            tab.cache.generation = current_generation;
2356            tab.cache.scroll_offset = tab.scroll_state.offset;
2357            tab.cache.cursor_pos = current_cursor_pos;
2358            tab.cache.selection = tab.mouse.selection;
2359        }
2360
2361        let mut show_scrollbar = self.should_show_scrollbar();
2362
2363        let (scrollback_len, terminal_title) = if let Ok(mut term) = terminal.try_lock() {
2364            // Use cursor row 0 when cursor not visible (e.g., alt screen)
2365            let cursor_row = current_cursor_pos.map(|(_, row)| row).unwrap_or(0);
2366            let sb_len = term.scrollback_len();
2367            term.update_scrollback_metadata(sb_len, cursor_row);
2368
2369            // Feed newly completed commands into persistent history from two sources:
2370            // 1. Scrollback marks (populated via set_mark_command_at from grid text extraction)
2371            // 2. Core library command history (populated by the terminal emulator core)
2372            // Both sources are checked because command text may come from either path
2373            // depending on shell integration quality. The synced_commands set prevents
2374            // duplicate adds across frames and sources.
2375            for mark in term.scrollback_marks() {
2376                if let Some(ref cmd) = mark.command
2377                    && !cmd.is_empty()
2378                    && self.synced_commands.insert(cmd.clone())
2379                {
2380                    self.command_history
2381                        .add(cmd.clone(), mark.exit_code, mark.duration_ms);
2382                }
2383            }
2384            for (cmd, exit_code, duration_ms) in term.core_command_history() {
2385                if !cmd.is_empty() && self.synced_commands.insert(cmd.clone()) {
2386                    self.command_history.add(cmd, exit_code, duration_ms);
2387                }
2388            }
2389
2390            (sb_len, term.get_title())
2391        } else {
2392            (cached_scrollback_len, cached_terminal_title.clone())
2393        };
2394
2395        // Update cache scrollback and clamp scroll state
2396        if let Some(tab) = self.tab_manager.active_tab_mut() {
2397            tab.cache.scrollback_len = scrollback_len;
2398            tab.scroll_state
2399                .clamp_to_scrollback(tab.cache.scrollback_len);
2400        }
2401
2402        // Keep copy mode dimensions in sync with terminal
2403        if self.copy_mode.active
2404            && let Ok(term) = terminal.try_lock()
2405        {
2406            let (cols, rows) = term.dimensions();
2407            self.copy_mode.update_dimensions(cols, rows, scrollback_len);
2408        }
2409
2410        let need_marks =
2411            self.config.scrollbar_command_marks || self.config.command_separator_enabled;
2412        let mut scrollback_marks = if need_marks {
2413            if let Ok(term) = terminal.try_lock() {
2414                term.scrollback_marks()
2415            } else {
2416                Vec::new()
2417            }
2418        } else {
2419            Vec::new()
2420        };
2421
2422        // Append trigger-generated marks
2423        if let Some(tab) = self.tab_manager.active_tab() {
2424            scrollback_marks.extend(tab.trigger_marks.iter().cloned());
2425        }
2426
2427        // Keep scrollbar visible when mark indicators exist (even if no scrollback).
2428        if !scrollback_marks.is_empty() {
2429            show_scrollbar = true;
2430        }
2431
2432        // Update window title if terminal has set one via OSC sequences
2433        // Only if allow_title_change is enabled and we're not showing a URL tooltip
2434        if self.config.allow_title_change
2435            && hovered_url.is_none()
2436            && terminal_title != cached_terminal_title
2437        {
2438            if let Some(tab) = self.tab_manager.active_tab_mut() {
2439                tab.cache.terminal_title = terminal_title.clone();
2440            }
2441            if let Some(window) = &self.window {
2442                if terminal_title.is_empty() {
2443                    // Restore configured title when terminal clears title
2444                    window.set_title(&self.format_title(&self.config.window_title));
2445                } else {
2446                    // Use terminal-set title with window number
2447                    window.set_title(&self.format_title(&terminal_title));
2448                }
2449            }
2450        }
2451
2452        // Total lines = visible lines + actual scrollback content
2453        let total_lines = visible_lines + scrollback_len;
2454
2455        // Detect URLs in visible area (only when content changed)
2456        // This optimization saves ~0.26ms per frame on cache hits
2457        let url_detect_start = std::time::Instant::now();
2458        let debug_url_detect_time = if !self.debug.cache_hit {
2459            // Content changed - re-detect URLs
2460            self.detect_urls();
2461            url_detect_start.elapsed()
2462        } else {
2463            // Content unchanged - use cached URL detection
2464            std::time::Duration::ZERO
2465        };
2466
2467        // Apply URL underlining to cells (always apply, since cells might be regenerated)
2468        let url_underline_start = std::time::Instant::now();
2469        let mut cells = cells; // Make cells mutable
2470        self.apply_url_underlines(&mut cells, &renderer_size);
2471        let _debug_url_underline_time = url_underline_start.elapsed();
2472
2473        // Update search and apply search highlighting
2474        if self.search_ui.visible {
2475            // Get all searchable lines from cells (ensures consistent wide character handling)
2476            if let Some(tab) = self.tab_manager.active_tab()
2477                && let Ok(term) = tab.terminal.try_lock()
2478            {
2479                let lines_iter =
2480                    crate::app::search_highlight::get_all_searchable_lines(&term, visible_lines);
2481                self.search_ui.update_search(lines_iter);
2482            }
2483
2484            // Apply search highlighting to visible cells
2485            let scroll_offset = self
2486                .tab_manager
2487                .active_tab()
2488                .map(|t| t.scroll_state.offset)
2489                .unwrap_or(0);
2490            // Use actual terminal grid columns from renderer
2491            self.apply_search_highlights(
2492                &mut cells,
2493                grid_cols,
2494                scroll_offset,
2495                scrollback_len,
2496                visible_lines,
2497            );
2498        }
2499
2500        // Update cursor blink state
2501        self.update_cursor_blink();
2502
2503        let render_start = std::time::Instant::now();
2504
2505        let mut debug_update_cells_time = std::time::Duration::ZERO;
2506        #[allow(unused_assignments)]
2507        let mut debug_graphics_time = std::time::Duration::ZERO;
2508        #[allow(unused_assignments)]
2509        let mut debug_actual_render_time = std::time::Duration::ZERO;
2510        let _ = &debug_actual_render_time;
2511        // Clipboard action to handle after rendering (declared here to survive renderer borrow)
2512        let mut pending_clipboard_action = ClipboardHistoryAction::None;
2513        // Command history action to handle after rendering
2514        let mut pending_command_history_action = CommandHistoryAction::None;
2515        // Paste special action to handle after rendering
2516        let mut pending_paste_special_action = PasteSpecialAction::None;
2517        // tmux session picker action to handle after rendering
2518        let mut pending_session_picker_action = SessionPickerAction::None;
2519        // Tab bar action to handle after rendering (declared here to survive renderer borrow)
2520        let mut pending_tab_action = TabBarAction::None;
2521        // Shader install response to handle after rendering
2522        let mut pending_shader_install_response = ShaderInstallResponse::None;
2523        // Integrations welcome dialog response to handle after rendering
2524        let mut pending_integrations_response = IntegrationsResponse::default();
2525        // Search action to handle after rendering
2526        let mut pending_search_action = crate::search::SearchAction::None;
2527        // AI Inspector action to handle after rendering
2528        let mut pending_inspector_action = InspectorAction::None;
2529        // Profile drawer action to handle after rendering
2530        let mut pending_profile_drawer_action = ProfileDrawerAction::None;
2531        // Close confirmation action to handle after rendering
2532        let mut pending_close_confirm_action = CloseConfirmAction::None;
2533        // Quit confirmation action to handle after rendering
2534        let mut pending_quit_confirm_action = QuitConfirmAction::None;
2535        let mut pending_remote_install_action = RemoteShellInstallAction::None;
2536        let mut pending_ssh_connect_action = SshConnectAction::None;
2537
2538        // Process agent messages
2539        let msg_count_before = self.ai_inspector.chat.messages.len();
2540        // Config update requests are deferred to avoid double-borrow of self
2541        // while iterating agent_rx.
2542        type ConfigUpdateEntry = (
2543            std::collections::HashMap<String, serde_json::Value>,
2544            tokio::sync::oneshot::Sender<Result<(), String>>,
2545        );
2546        let mut pending_config_updates: Vec<ConfigUpdateEntry> = Vec::new();
2547        if let Some(rx) = &mut self.agent_rx {
2548            while let Ok(msg) = rx.try_recv() {
2549                match msg {
2550                    AgentMessage::StatusChanged(status) => {
2551                        // Flush any pending agent text on status change.
2552                        self.ai_inspector.chat.flush_agent_message();
2553                        self.ai_inspector.agent_status = status;
2554                        self.needs_redraw = true;
2555                    }
2556                    AgentMessage::SessionUpdate(update) => {
2557                        self.ai_inspector.chat.handle_update(update);
2558                        self.needs_redraw = true;
2559                    }
2560                    AgentMessage::PermissionRequest {
2561                        request_id,
2562                        tool_call,
2563                        options,
2564                    } => {
2565                        log::info!(
2566                            "ACP: permission request id={request_id} options={}",
2567                            options.len()
2568                        );
2569                        let description = tool_call
2570                            .get("title")
2571                            .and_then(|t| t.as_str())
2572                            .unwrap_or("Permission requested")
2573                            .to_string();
2574                        self.ai_inspector
2575                            .chat
2576                            .messages
2577                            .push(ChatMessage::Permission {
2578                                request_id,
2579                                description,
2580                                options: options
2581                                    .iter()
2582                                    .map(|o| (o.option_id.clone(), o.name.clone()))
2583                                    .collect(),
2584                                resolved: false,
2585                            });
2586                        self.needs_redraw = true;
2587                    }
2588                    AgentMessage::PromptComplete => {
2589                        self.ai_inspector.chat.flush_agent_message();
2590                        self.needs_redraw = true;
2591                    }
2592                    AgentMessage::ConfigUpdate { updates, reply } => {
2593                        pending_config_updates.push((updates, reply));
2594                    }
2595                    AgentMessage::ClientReady(client) => {
2596                        log::info!("ACP: agent_client ready");
2597                        self.agent_client = Some(client);
2598                    }
2599                }
2600            }
2601        }
2602        // Process deferred config updates now that agent_rx borrow is released.
2603        for (updates, reply) in pending_config_updates {
2604            let result = self.apply_agent_config_updates(&updates);
2605            if result.is_ok() {
2606                self.config_changed_by_agent = true;
2607            }
2608            let _ = reply.send(result);
2609            self.needs_redraw = true;
2610        }
2611
2612        // Auto-execute new CommandSuggestion messages when terminal access is enabled.
2613        if self.config.ai_inspector_agent_terminal_access {
2614            let new_messages = &self.ai_inspector.chat.messages[msg_count_before..];
2615            let commands_to_run: Vec<String> = new_messages
2616                .iter()
2617                .filter_map(|msg| {
2618                    if let ChatMessage::CommandSuggestion(cmd) = msg {
2619                        Some(format!("{cmd}\n"))
2620                    } else {
2621                        None
2622                    }
2623                })
2624                .collect();
2625
2626            if !commands_to_run.is_empty()
2627                && let Some(tab) = self.tab_manager.active_tab()
2628                && let Ok(term) = tab.terminal.try_lock()
2629            {
2630                for cmd in &commands_to_run {
2631                    let _ = term.write(cmd.as_bytes());
2632                }
2633                crate::debug_info!(
2634                    "AI_INSPECTOR",
2635                    "Auto-executed {} command(s) in terminal",
2636                    commands_to_run.len()
2637                );
2638            }
2639        }
2640
2641        // Detect new command completions and auto-refresh the snapshot.
2642        // This is separate from agent auto-context so the panel always shows
2643        // up-to-date command history regardless of agent connection state.
2644        if self.ai_inspector.open
2645            && let Some(tab) = self.tab_manager.active_tab()
2646            && let Ok(term) = tab.terminal.try_lock()
2647        {
2648            let history = term.core_command_history();
2649            let current_count = history.len();
2650
2651            if current_count != self.ai_inspector.last_command_count {
2652                // Command count changed — refresh the snapshot
2653                let had_commands = self.ai_inspector.last_command_count > 0;
2654                self.ai_inspector.last_command_count = current_count;
2655                self.ai_inspector.needs_refresh = true;
2656
2657                // Auto-context feeding: send latest command info to agent
2658                if had_commands
2659                    && current_count > 0
2660                    && self.config.ai_inspector_auto_context
2661                    && self.ai_inspector.agent_status == AgentStatus::Connected
2662                    && let Some((cmd, exit_code, duration_ms)) = history.last()
2663                {
2664                    let exit_code_str = exit_code
2665                        .map(|c| c.to_string())
2666                        .unwrap_or_else(|| "N/A".to_string());
2667                    let duration = duration_ms.unwrap_or(0);
2668
2669                    let cwd = term.shell_integration_cwd().unwrap_or_default();
2670
2671                    let context = format!(
2672                        "Command completed:\n$ {}\nExit code: {}\nDuration: {}ms\nCWD: {}",
2673                        cmd, exit_code_str, duration, cwd
2674                    );
2675
2676                    if let Some(agent) = &self.agent {
2677                        let agent = agent.clone();
2678                        let content = vec![par_term_acp::ContentBlock::Text { text: context }];
2679                        self.runtime.spawn(async move {
2680                            let agent = agent.lock().await;
2681                            let _ = agent.send_prompt(content).await;
2682                        });
2683                    }
2684                }
2685            }
2686        }
2687
2688        // Refresh AI Inspector snapshot if needed
2689        if self.ai_inspector.open
2690            && self.ai_inspector.needs_refresh
2691            && let Some(tab) = self.tab_manager.active_tab()
2692            && let Ok(term) = tab.terminal.try_lock()
2693        {
2694            let snapshot = crate::ai_inspector::snapshot::SnapshotData::gather(
2695                &term,
2696                &self.ai_inspector.scope,
2697                self.config.ai_inspector_context_max_lines,
2698            );
2699            self.ai_inspector.snapshot = Some(snapshot);
2700            self.ai_inspector.needs_refresh = false;
2701        }
2702
2703        // Check tmux gateway state before renderer borrow to avoid borrow conflicts
2704        // When tmux controls the layout, we don't use pane padding
2705        // Note: pane_padding is in logical pixels (config); we defer DPI scaling to
2706        // where it's used with physical pixel coordinates (via sizing.scale_factor).
2707        let is_tmux_gateway = self.is_gateway_active();
2708        let effective_pane_padding = if is_tmux_gateway {
2709            0.0
2710        } else {
2711            self.config.pane_padding
2712        };
2713
2714        // Calculate status bar height before mutable renderer borrow
2715        // Note: This is in logical pixels; it gets scaled to physical in RendererSizing.
2716        let is_tmux_connected = self.is_tmux_connected();
2717        let status_bar_height =
2718            crate::tmux_status_bar_ui::TmuxStatusBarUI::height(&self.config, is_tmux_connected);
2719
2720        // Calculate custom status bar height
2721        let custom_status_bar_height = self.status_bar_ui.height(&self.config, self.is_fullscreen);
2722
2723        // Capture window size before mutable borrow (for badge rendering in egui)
2724        let window_size_for_badge = self.renderer.as_ref().map(|r| r.size());
2725
2726        // Capture progress bar snapshot before mutable borrow
2727        let progress_snapshot = if self.config.progress_bar_enabled {
2728            self.tab_manager.active_tab().and_then(|tab| {
2729                tab.terminal
2730                    .try_lock()
2731                    .ok()
2732                    .map(|term| ProgressBarSnapshot {
2733                        simple: term.progress_bar(),
2734                        named: term.named_progress_bars(),
2735                    })
2736            })
2737        } else {
2738            None
2739        };
2740
2741        // Sync AI Inspector panel width before scrollbar update so the scrollbar
2742        // position uses the current panel width on this frame (not the previous one).
2743        self.sync_ai_inspector_width();
2744
2745        if let Some(renderer) = &mut self.renderer {
2746            // Status bar inset is handled by sync_status_bar_inset() above,
2747            // before cell gathering, so the grid height is correct.
2748
2749            // Disable cursor shader when alt screen is active (TUI apps like vim, htop)
2750            let disable_cursor_shader =
2751                self.config.cursor_shader_disable_in_alt_screen && is_alt_screen;
2752            renderer.set_cursor_shader_disabled_for_alt_screen(disable_cursor_shader);
2753
2754            // Only update renderer with cells if they changed (cache MISS)
2755            // This avoids re-uploading the same cell data to GPU on every frame
2756            if !self.debug.cache_hit {
2757                let t = std::time::Instant::now();
2758                renderer.update_cells(&cells);
2759                debug_update_cells_time = t.elapsed();
2760            }
2761
2762            // Update cursor position and style for geometric rendering
2763            if let (Some(pos), Some(opacity), Some(style)) =
2764                (current_cursor_pos, Some(self.cursor_opacity), cursor_style)
2765            {
2766                renderer.update_cursor(pos, opacity, style);
2767                // Forward cursor state to custom shader for Ghostty-compatible cursor animations
2768                // Use the configured cursor color
2769                let cursor_color = [
2770                    self.config.cursor_color[0] as f32 / 255.0,
2771                    self.config.cursor_color[1] as f32 / 255.0,
2772                    self.config.cursor_color[2] as f32 / 255.0,
2773                    1.0,
2774                ];
2775                renderer.update_shader_cursor(pos.0, pos.1, opacity, cursor_color, style);
2776            } else {
2777                renderer.clear_cursor();
2778            }
2779
2780            // Update progress bar state for shader uniforms
2781            if let Some(ref snap) = progress_snapshot {
2782                use par_term_emu_core_rust::terminal::ProgressState;
2783                let state_val = match snap.simple.state {
2784                    ProgressState::Hidden => 0.0,
2785                    ProgressState::Normal => 1.0,
2786                    ProgressState::Error => 2.0,
2787                    ProgressState::Indeterminate => 3.0,
2788                    ProgressState::Warning => 4.0,
2789                };
2790                let active_count = (if snap.simple.is_active() { 1 } else { 0 })
2791                    + snap.named.values().filter(|b| b.state.is_active()).count();
2792                renderer.update_shader_progress(
2793                    state_val,
2794                    snap.simple.progress as f32 / 100.0,
2795                    if snap.has_active() { 1.0 } else { 0.0 },
2796                    active_count as f32,
2797                );
2798            } else {
2799                renderer.update_shader_progress(0.0, 0.0, 0.0, 0.0);
2800            }
2801
2802            // Update scrollbar
2803            let scroll_offset = self
2804                .tab_manager
2805                .active_tab()
2806                .map(|t| t.scroll_state.offset)
2807                .unwrap_or(0);
2808            renderer.update_scrollbar(scroll_offset, visible_lines, total_lines, &scrollback_marks);
2809
2810            // Compute and set command separator marks for single-pane rendering
2811            if self.config.command_separator_enabled {
2812                let separator_marks = crate::renderer::compute_visible_separator_marks(
2813                    &scrollback_marks,
2814                    scrollback_len,
2815                    scroll_offset,
2816                    visible_lines,
2817                );
2818                renderer.set_separator_marks(separator_marks);
2819            } else {
2820                renderer.set_separator_marks(Vec::new());
2821            }
2822
2823            // Update animations and request redraw if frames changed
2824            let anim_start = std::time::Instant::now();
2825            if let Some(tab) = self.tab_manager.active_tab() {
2826                let terminal = tab.terminal.blocking_lock();
2827                if terminal.update_animations() {
2828                    // Animation frame changed - request continuous redraws while animations are playing
2829                    if let Some(window) = &self.window {
2830                        window.request_redraw();
2831                    }
2832                }
2833            }
2834            let debug_anim_time = anim_start.elapsed();
2835
2836            // Update graphics from terminal (pass scroll_offset for view adjustment)
2837            // Include both current screen graphics and scrollback graphics
2838            // Use get_graphics_with_animations() to get current animation frames
2839            let graphics_start = std::time::Instant::now();
2840            if let Some(tab) = self.tab_manager.active_tab() {
2841                let terminal = tab.terminal.blocking_lock();
2842                let mut graphics = terminal.get_graphics_with_animations();
2843                let scrollback_len = terminal.scrollback_len();
2844
2845                // Always include scrollback graphics (renderer will calculate visibility)
2846                let scrollback_graphics = terminal.get_scrollback_graphics();
2847                let scrollback_count = scrollback_graphics.len();
2848                graphics.extend(scrollback_graphics);
2849
2850                debug_info!(
2851                    "APP",
2852                    "Got {} graphics ({} scrollback) from terminal (scroll_offset={}, scrollback_len={})",
2853                    graphics.len(),
2854                    scrollback_count,
2855                    scroll_offset,
2856                    scrollback_len
2857                );
2858                if let Err(e) = renderer.update_graphics(
2859                    &graphics,
2860                    scroll_offset,
2861                    scrollback_len,
2862                    visible_lines,
2863                ) {
2864                    log::error!("Failed to update graphics: {}", e);
2865                }
2866            }
2867            debug_graphics_time = graphics_start.elapsed();
2868
2869            // Calculate visual bell flash intensity (0.0 = no flash, 1.0 = full flash)
2870            let visual_bell_flash = self
2871                .tab_manager
2872                .active_tab()
2873                .and_then(|t| t.bell.visual_flash);
2874            let visual_bell_intensity = if let Some(flash_start) = visual_bell_flash {
2875                const FLASH_DURATION_MS: u128 = 150;
2876                let elapsed = flash_start.elapsed().as_millis();
2877                if elapsed < FLASH_DURATION_MS {
2878                    // Request continuous redraws while flash is active
2879                    if let Some(window) = &self.window {
2880                        window.request_redraw();
2881                    }
2882                    // Fade out: start at 0.3 intensity, fade to 0
2883                    0.3 * (1.0 - (elapsed as f32 / FLASH_DURATION_MS as f32))
2884                } else {
2885                    // Flash complete - clear it
2886                    if let Some(tab) = self.tab_manager.active_tab_mut() {
2887                        tab.bell.visual_flash = None;
2888                    }
2889                    0.0
2890                }
2891            } else {
2892                0.0
2893            };
2894
2895            // Update renderer with visual bell intensity
2896            renderer.set_visual_bell_intensity(visual_bell_intensity);
2897
2898            // Prepare egui output if settings UI is visible
2899            let egui_start = std::time::Instant::now();
2900
2901            // Capture values for FPS overlay before closure
2902            let show_fps = self.debug.show_fps_overlay;
2903            let fps_value = self.debug.fps_value;
2904            let frame_time_ms = if !self.debug.frame_times.is_empty() {
2905                let avg = self.debug.frame_times.iter().sum::<std::time::Duration>()
2906                    / self.debug.frame_times.len() as u32;
2907                avg.as_secs_f64() * 1000.0
2908            } else {
2909                0.0
2910            };
2911
2912            // Capture badge state for closure (uses window_size_for_badge captured earlier)
2913            let badge_enabled = self.badge_state.enabled;
2914            let badge_state = if badge_enabled {
2915                // Update variables if dirty
2916                if self.badge_state.is_dirty() {
2917                    self.badge_state.interpolate();
2918                }
2919                Some(self.badge_state.clone())
2920            } else {
2921                None
2922            };
2923
2924            // Capture session variables for status bar rendering
2925            let status_bar_session_vars = if self.config.status_bar_enabled {
2926                Some(self.badge_state.variables.read().clone())
2927            } else {
2928                None
2929            };
2930
2931            // Capture hovered scrollbar mark for tooltip display
2932            let hovered_mark: Option<crate::scrollback_metadata::ScrollbackMark> =
2933                if self.config.scrollbar_mark_tooltips && self.config.scrollbar_command_marks {
2934                    self.tab_manager
2935                        .active_tab()
2936                        .map(|tab| tab.mouse.position)
2937                        .and_then(|(mx, my)| {
2938                            renderer.scrollbar_mark_at_position(mx as f32, my as f32, 8.0)
2939                        })
2940                        .cloned()
2941                } else {
2942                    None
2943                };
2944
2945            // Collect pane bounds for identify overlay (before egui borrow)
2946            let pane_identify_bounds: Vec<(usize, crate::pane::PaneBounds)> =
2947                if self.pane_identify_hide_time.is_some() {
2948                    self.tab_manager
2949                        .active_tab()
2950                        .and_then(|tab| tab.pane_manager())
2951                        .map(|pm| {
2952                            pm.all_panes()
2953                                .iter()
2954                                .enumerate()
2955                                .map(|(i, pane)| (i, pane.bounds))
2956                                .collect()
2957                        })
2958                        .unwrap_or_default()
2959                } else {
2960                    Vec::new()
2961                };
2962
2963            let egui_data = if let (Some(egui_ctx), Some(egui_state)) =
2964                (&self.egui_ctx, &mut self.egui_state)
2965            {
2966                let mut raw_input = egui_state.take_egui_input(self.window.as_ref().unwrap());
2967
2968                // Inject pending events from menu accelerators (Cmd+V/C/A intercepted by muda)
2969                // when egui overlays (profile modal, search, etc.) are active
2970                raw_input.events.append(&mut self.pending_egui_events);
2971
2972                // When no modal UI overlay is visible, filter out Tab key events to prevent
2973                // egui's default focus navigation from stealing Tab/Shift+Tab from the terminal.
2974                // Tab/Shift+Tab should only cycle focus between egui widgets when a modal is open.
2975                // Note: Side panels (ai_inspector, profile drawer) are NOT modals — the terminal
2976                // should still receive Tab/Shift+Tab when they are open.
2977                let any_modal_visible = self.help_ui.visible
2978                    || self.clipboard_history_ui.visible
2979                    || self.command_history_ui.visible
2980                    || self.shader_install_ui.visible
2981                    || self.integrations_ui.visible
2982                    || self.search_ui.visible
2983                    || self.tmux_session_picker_ui.visible
2984                    || self.ssh_connect_ui.is_visible()
2985                    || self.quit_confirmation_ui.is_visible()
2986                    || self.remote_shell_install_ui.is_visible();
2987                if !any_modal_visible {
2988                    raw_input.events.retain(|e| {
2989                        !matches!(
2990                            e,
2991                            egui::Event::Key {
2992                                key: egui::Key::Tab,
2993                                ..
2994                            }
2995                        )
2996                    });
2997                }
2998
2999                let egui_output = egui_ctx.run(raw_input, |ctx| {
3000                    // Show FPS overlay if enabled (top-right corner)
3001                    if show_fps {
3002                        egui::Area::new(egui::Id::new("fps_overlay"))
3003                            .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-30.0, 10.0))
3004                            .order(egui::Order::Foreground)
3005                            .show(ctx, |ui| {
3006                                egui::Frame::NONE
3007                                    .fill(egui::Color32::from_rgba_unmultiplied(0, 0, 0, 200))
3008                                    .inner_margin(egui::Margin::same(8))
3009                                    .corner_radius(4.0)
3010                                    .show(ui, |ui| {
3011                                        ui.style_mut().visuals.override_text_color =
3012                                            Some(egui::Color32::from_rgb(0, 255, 0));
3013                                        ui.label(
3014                                            egui::RichText::new(format!(
3015                                                "FPS: {:.1}\nFrame: {:.2}ms",
3016                                                fps_value, frame_time_ms
3017                                            ))
3018                                            .monospace()
3019                                            .size(14.0),
3020                                        );
3021                                    });
3022                            });
3023                    }
3024
3025                    // Show resize overlay if active (centered)
3026                    if self.resize_overlay_visible
3027                        && let Some((width_px, height_px, cols, rows)) = self.resize_dimensions
3028                    {
3029                        egui::Area::new(egui::Id::new("resize_overlay"))
3030                            .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
3031                            .order(egui::Order::Foreground)
3032                            .show(ctx, |ui| {
3033                                egui::Frame::NONE
3034                                    .fill(egui::Color32::from_rgba_unmultiplied(0, 0, 0, 220))
3035                                    .inner_margin(egui::Margin::same(16))
3036                                    .corner_radius(8.0)
3037                                    .show(ui, |ui| {
3038                                        ui.style_mut().visuals.override_text_color =
3039                                            Some(egui::Color32::from_rgb(255, 255, 255));
3040                                        ui.label(
3041                                            egui::RichText::new(format!(
3042                                                "{}×{}\n{}×{} px",
3043                                                cols, rows, width_px, height_px
3044                                            ))
3045                                            .monospace()
3046                                            .size(24.0),
3047                                        );
3048                                    });
3049                            });
3050                    }
3051
3052                    // Show copy mode status bar overlay (when enabled in config)
3053                    if self.copy_mode.active && self.config.copy_mode_show_status {
3054                        let status = self.copy_mode.status_text();
3055                        let (mode_text, color) = if self.copy_mode.is_searching {
3056                            ("SEARCH", egui::Color32::from_rgb(255, 165, 0))
3057                        } else {
3058                            match self.copy_mode.visual_mode {
3059                                crate::copy_mode::VisualMode::None => {
3060                                    ("COPY", egui::Color32::from_rgb(100, 200, 100))
3061                                }
3062                                crate::copy_mode::VisualMode::Char => {
3063                                    ("VISUAL", egui::Color32::from_rgb(100, 150, 255))
3064                                }
3065                                crate::copy_mode::VisualMode::Line => {
3066                                    ("V-LINE", egui::Color32::from_rgb(100, 150, 255))
3067                                }
3068                                crate::copy_mode::VisualMode::Block => {
3069                                    ("V-BLOCK", egui::Color32::from_rgb(100, 150, 255))
3070                                }
3071                            }
3072                        };
3073
3074                        egui::Area::new(egui::Id::new("copy_mode_status_bar"))
3075                            .anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(0.0, 0.0))
3076                            .order(egui::Order::Foreground)
3077                            .show(ctx, |ui| {
3078                                let available_width = ui.available_width();
3079                                egui::Frame::NONE
3080                                    .fill(egui::Color32::from_rgba_unmultiplied(40, 40, 40, 230))
3081                                    .inner_margin(egui::Margin::symmetric(12, 6))
3082                                    .show(ui, |ui| {
3083                                        ui.set_min_width(available_width);
3084                                        ui.horizontal(|ui| {
3085                                            ui.label(
3086                                                egui::RichText::new(mode_text)
3087                                                    .monospace()
3088                                                    .size(13.0)
3089                                                    .color(color)
3090                                                    .strong(),
3091                                            );
3092                                            ui.separator();
3093                                            ui.label(
3094                                                egui::RichText::new(&status)
3095                                                    .monospace()
3096                                                    .size(12.0)
3097                                                    .color(egui::Color32::from_rgb(200, 200, 200)),
3098                                            );
3099                                        });
3100                                    });
3101                            });
3102                    }
3103
3104                    // Show toast notification if active (top center)
3105                    if let Some(ref message) = self.toast_message {
3106                        egui::Area::new(egui::Id::new("toast_notification"))
3107                            .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 60.0))
3108                            .order(egui::Order::Foreground)
3109                            .show(ctx, |ui| {
3110                                egui::Frame::NONE
3111                                    .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 240))
3112                                    .inner_margin(egui::Margin::symmetric(20, 12))
3113                                    .corner_radius(8.0)
3114                                    .stroke(egui::Stroke::new(
3115                                        1.0,
3116                                        egui::Color32::from_rgb(80, 80, 80),
3117                                    ))
3118                                    .show(ui, |ui| {
3119                                        ui.style_mut().visuals.override_text_color =
3120                                            Some(egui::Color32::from_rgb(255, 255, 255));
3121                                        ui.label(egui::RichText::new(message).size(16.0));
3122                                    });
3123                            });
3124                    }
3125
3126                    // Show scrollbar mark tooltip if hovering over a mark
3127                    if let Some(ref mark) = hovered_mark {
3128                        // Format the tooltip content
3129                        let mut lines = Vec::new();
3130
3131                        if let Some(ref cmd) = mark.command {
3132                            let truncated = if cmd.len() > 50 {
3133                                format!("{}...", &cmd[..47])
3134                            } else {
3135                                cmd.clone()
3136                            };
3137                            lines.push(format!("Command: {}", truncated));
3138                        }
3139
3140                        if let Some(start_time) = mark.start_time {
3141                            use chrono::{DateTime, Local, Utc};
3142                            let dt =
3143                                DateTime::<Utc>::from_timestamp_millis(start_time as i64).unwrap();
3144                            let local: DateTime<Local> = dt.into();
3145                            lines.push(format!("Time: {}", local.format("%H:%M:%S")));
3146                        }
3147
3148                        if let Some(duration_ms) = mark.duration_ms {
3149                            if duration_ms < 1000 {
3150                                lines.push(format!("Duration: {}ms", duration_ms));
3151                            } else if duration_ms < 60000 {
3152                                lines
3153                                    .push(format!("Duration: {:.1}s", duration_ms as f64 / 1000.0));
3154                            } else {
3155                                let mins = duration_ms / 60000;
3156                                let secs = (duration_ms % 60000) / 1000;
3157                                lines.push(format!("Duration: {}m {}s", mins, secs));
3158                            }
3159                        }
3160
3161                        if let Some(exit_code) = mark.exit_code {
3162                            lines.push(format!("Exit: {}", exit_code));
3163                        }
3164
3165                        let tooltip_text = lines.join("\n");
3166
3167                        // Calculate tooltip position, clamped to stay on screen
3168                        let mouse_pos = ctx.pointer_hover_pos().unwrap_or(egui::pos2(100.0, 100.0));
3169                        let tooltip_x = (mouse_pos.x - 180.0).max(10.0);
3170                        let tooltip_y = (mouse_pos.y - 20.0).max(10.0);
3171
3172                        // Show tooltip near mouse position (offset to the left of scrollbar)
3173                        egui::Area::new(egui::Id::new("scrollbar_mark_tooltip"))
3174                            .order(egui::Order::Tooltip)
3175                            .fixed_pos(egui::pos2(tooltip_x, tooltip_y))
3176                            .show(ctx, |ui| {
3177                                ui.set_min_width(150.0);
3178                                egui::Frame::NONE
3179                                    .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 240))
3180                                    .inner_margin(egui::Margin::same(8))
3181                                    .corner_radius(4.0)
3182                                    .stroke(egui::Stroke::new(
3183                                        1.0,
3184                                        egui::Color32::from_rgb(80, 80, 80),
3185                                    ))
3186                                    .show(ui, |ui| {
3187                                        ui.set_min_width(140.0);
3188                                        ui.style_mut().visuals.override_text_color =
3189                                            Some(egui::Color32::from_rgb(220, 220, 220));
3190                                        ui.label(
3191                                            egui::RichText::new(&tooltip_text)
3192                                                .monospace()
3193                                                .size(12.0),
3194                                        );
3195                                    });
3196                            });
3197                    }
3198
3199                    // Render tab bar if visible (action handled after closure)
3200                    pending_tab_action = self.tab_bar_ui.render(
3201                        ctx,
3202                        &self.tab_manager,
3203                        &self.config,
3204                        &self.profile_manager,
3205                    );
3206
3207                    // Render tmux status bar if connected
3208                    self.tmux_status_bar_ui.render(
3209                        ctx,
3210                        &self.config,
3211                        self.tmux_session.as_ref(),
3212                        self.tmux_session_name.as_deref(),
3213                    );
3214
3215                    // Render custom status bar
3216                    if let Some(ref session_vars) = status_bar_session_vars {
3217                        let (_bar_height, status_bar_action) = self.status_bar_ui.render(
3218                            ctx,
3219                            &self.config,
3220                            session_vars,
3221                            self.is_fullscreen,
3222                        );
3223                        if status_bar_action
3224                            == Some(crate::status_bar::StatusBarAction::ShowUpdateDialog)
3225                        {
3226                            self.show_update_dialog = true;
3227                        }
3228                    }
3229
3230                    // Settings are now handled by standalone SettingsWindow only
3231                    // No overlay settings UI rendering needed
3232
3233                    // Show help UI
3234                    self.help_ui.show(ctx);
3235
3236                    // Show clipboard history UI and collect action
3237                    pending_clipboard_action = self.clipboard_history_ui.show(ctx);
3238
3239                    // Show command history UI and collect action
3240                    pending_command_history_action = self.command_history_ui.show(ctx);
3241
3242                    // Show paste special UI and collect action
3243                    pending_paste_special_action = self.paste_special_ui.show(ctx);
3244
3245                    // Show search UI and collect action
3246                    pending_search_action = self.search_ui.show(ctx, visible_lines, scrollback_len);
3247
3248                    // Show AI Inspector panel and collect action
3249                    pending_inspector_action = self.ai_inspector.show(ctx, &self.available_agents);
3250
3251                    // Show tmux session picker UI and collect action
3252                    let tmux_path = self.config.resolve_tmux_path();
3253                    pending_session_picker_action =
3254                        self.tmux_session_picker_ui.show(ctx, &tmux_path);
3255
3256                    // Show shader install dialog if visible
3257                    pending_shader_install_response = self.shader_install_ui.show(ctx);
3258
3259                    // Show integrations welcome dialog if visible
3260                    pending_integrations_response = self.integrations_ui.show(ctx);
3261
3262                    // Show close confirmation dialog if visible
3263                    pending_close_confirm_action = self.close_confirmation_ui.show(ctx);
3264
3265                    // Show quit confirmation dialog if visible
3266                    pending_quit_confirm_action = self.quit_confirmation_ui.show(ctx);
3267
3268                    // Show remote shell install dialog if visible
3269                    pending_remote_install_action = self.remote_shell_install_ui.show(ctx);
3270
3271                    // Show SSH Quick Connect dialog if visible
3272                    pending_ssh_connect_action = self.ssh_connect_ui.show(ctx);
3273
3274                    // Render update dialog overlay
3275                    if self.show_update_dialog {
3276                        // Poll for update install completion
3277                        if let Some(ref rx) = self.update_install_receiver
3278                            && let Ok(result) = rx.try_recv()
3279                        {
3280                            match result {
3281                                Ok(update_result) => {
3282                                    self.update_install_status = Some(format!(
3283                                        "Updated to v{}! Restart par-term to use the new version.",
3284                                        update_result.new_version
3285                                    ));
3286                                    self.update_installing = false;
3287                                    self.status_bar_ui.update_available_version = None;
3288                                }
3289                                Err(e) => {
3290                                    self.update_install_status =
3291                                        Some(format!("Update failed: {}", e));
3292                                    self.update_installing = false;
3293                                }
3294                            }
3295                            self.update_install_receiver = None;
3296                        }
3297
3298                        if let Some(ref update_result) = self.last_update_result {
3299                            let dialog_action = crate::update_dialog::render(
3300                                ctx,
3301                                update_result,
3302                                env!("CARGO_PKG_VERSION"),
3303                                self.installation_type,
3304                                self.update_installing,
3305                                self.update_install_status.as_deref(),
3306                            );
3307                            match dialog_action {
3308                                crate::update_dialog::UpdateDialogAction::Dismiss => {
3309                                    if !self.update_installing {
3310                                        self.show_update_dialog = false;
3311                                        self.update_install_status = None;
3312                                    }
3313                                }
3314                                crate::update_dialog::UpdateDialogAction::SkipVersion(v) => {
3315                                    self.config.skipped_version = Some(v);
3316                                    self.show_update_dialog = false;
3317                                    self.status_bar_ui.update_available_version = None;
3318                                    self.update_install_status = None;
3319                                    let _ = self.config.save();
3320                                }
3321                                crate::update_dialog::UpdateDialogAction::InstallUpdate(v) => {
3322                                    if !self.update_installing {
3323                                        self.update_installing = true;
3324                                        self.update_install_status =
3325                                            Some("Downloading update...".to_string());
3326                                        let (tx, rx) = std::sync::mpsc::channel();
3327                                        self.update_install_receiver = Some(rx);
3328                                        let version = v.clone();
3329                                        std::thread::spawn(move || {
3330                                            let result =
3331                                                crate::self_updater::perform_update(&version);
3332                                            let _ = tx.send(result);
3333                                        });
3334                                    }
3335                                    // Don't close dialog while installing
3336                                }
3337                                crate::update_dialog::UpdateDialogAction::None => {}
3338                            }
3339                        } else {
3340                            // No update result, close dialog
3341                            self.show_update_dialog = false;
3342                        }
3343                    }
3344
3345                    // Render profile drawer (right side panel)
3346                    pending_profile_drawer_action = self.profile_drawer_ui.render(
3347                        ctx,
3348                        &self.profile_manager,
3349                        &self.config,
3350                        false, // profile modal is no longer in the terminal window
3351                    );
3352
3353                    // Render progress bar overlay
3354                    if let (Some(snap), Some(size)) = (&progress_snapshot, window_size_for_badge) {
3355                        render_progress_bars(
3356                            ctx,
3357                            snap,
3358                            &self.config,
3359                            size.width as f32,
3360                            size.height as f32,
3361                        );
3362                    }
3363
3364                    // Render pane identify overlay (large index numbers centered on each pane)
3365                    if !pane_identify_bounds.is_empty() {
3366                        for (index, bounds) in &pane_identify_bounds {
3367                            let center_x = bounds.x + bounds.width / 2.0;
3368                            let center_y = bounds.y + bounds.height / 2.0;
3369                            egui::Area::new(egui::Id::new(format!("pane_identify_{}", index)))
3370                                .fixed_pos(egui::pos2(center_x - 30.0, center_y - 30.0))
3371                                .order(egui::Order::Foreground)
3372                                .interactable(false)
3373                                .show(ctx, |ui| {
3374                                    egui::Frame::NONE
3375                                        .fill(egui::Color32::from_rgba_unmultiplied(0, 0, 0, 200))
3376                                        .inner_margin(egui::Margin::symmetric(16, 8))
3377                                        .corner_radius(8.0)
3378                                        .stroke(egui::Stroke::new(
3379                                            2.0,
3380                                            egui::Color32::from_rgb(100, 200, 255),
3381                                        ))
3382                                        .show(ui, |ui| {
3383                                            ui.label(
3384                                                egui::RichText::new(format!("Pane {}", index))
3385                                                    .monospace()
3386                                                    .size(28.0)
3387                                                    .color(egui::Color32::from_rgb(100, 200, 255)),
3388                                            );
3389                                        });
3390                                });
3391                        }
3392                    }
3393
3394                    // Render file transfer progress overlay (bottom-right corner)
3395                    crate::app::file_transfers::render_file_transfer_overlay(
3396                        &self.file_transfer_state,
3397                        ctx,
3398                    );
3399
3400                    // Render badge overlay (top-right corner)
3401                    if let (Some(badge), Some(size)) = (&badge_state, window_size_for_badge) {
3402                        render_badge(ctx, badge, size.width as f32, size.height as f32);
3403                    }
3404                });
3405
3406                // Handle egui platform output (clipboard, cursor changes, etc.)
3407                // This enables cut/copy/paste in egui text editors
3408                egui_state.handle_platform_output(
3409                    self.window.as_ref().unwrap(),
3410                    egui_output.platform_output.clone(),
3411                );
3412
3413                Some((egui_output, egui_ctx))
3414            } else {
3415                None
3416            };
3417
3418            // Mark egui as initialized after first ctx.run() - makes is_using_pointer() reliable
3419            if !self.egui_initialized && egui_data.is_some() {
3420                self.egui_initialized = true;
3421            }
3422
3423            // Settings are now handled exclusively by standalone SettingsWindow
3424            // Config changes are applied via window_manager.apply_config_to_windows()
3425
3426            let debug_egui_time = egui_start.elapsed();
3427
3428            // Calculate FPS and timing stats
3429            let avg_frame_time = if !self.debug.frame_times.is_empty() {
3430                self.debug.frame_times.iter().sum::<std::time::Duration>()
3431                    / self.debug.frame_times.len() as u32
3432            } else {
3433                std::time::Duration::ZERO
3434            };
3435            let fps = if avg_frame_time.as_secs_f64() > 0.0 {
3436                1.0 / avg_frame_time.as_secs_f64()
3437            } else {
3438                0.0
3439            };
3440
3441            // Update FPS value for overlay display
3442            self.debug.fps_value = fps;
3443
3444            // Log debug info every 60 frames (about once per second at 60 FPS)
3445            if self.debug.frame_times.len() >= 60 {
3446                let (cache_gen, cache_has_cells) = self
3447                    .tab_manager
3448                    .active_tab()
3449                    .map(|t| (t.cache.generation, t.cache.cells.is_some()))
3450                    .unwrap_or((0, false));
3451                log::info!(
3452                    "PERF: FPS={:.1} Frame={:.2}ms CellGen={:.2}ms({}) URLDetect={:.2}ms Anim={:.2}ms Graphics={:.2}ms egui={:.2}ms UpdateCells={:.2}ms ActualRender={:.2}ms Total={:.2}ms Cells={} Gen={} Cache={}",
3453                    fps,
3454                    avg_frame_time.as_secs_f64() * 1000.0,
3455                    self.debug.cell_gen_time.as_secs_f64() * 1000.0,
3456                    if self.debug.cache_hit { "HIT" } else { "MISS" },
3457                    debug_url_detect_time.as_secs_f64() * 1000.0,
3458                    debug_anim_time.as_secs_f64() * 1000.0,
3459                    debug_graphics_time.as_secs_f64() * 1000.0,
3460                    debug_egui_time.as_secs_f64() * 1000.0,
3461                    debug_update_cells_time.as_secs_f64() * 1000.0,
3462                    debug_actual_render_time.as_secs_f64() * 1000.0,
3463                    self.debug.render_time.as_secs_f64() * 1000.0,
3464                    cells.len(),
3465                    cache_gen,
3466                    if cache_has_cells { "YES" } else { "NO" }
3467                );
3468            }
3469
3470            // Render (with dirty tracking optimization)
3471            let actual_render_start = std::time::Instant::now();
3472            // Settings are handled by standalone SettingsWindow, not embedded UI
3473
3474            // Extract renderer sizing info for split pane calculations
3475            let sizing = RendererSizing {
3476                size: renderer.size(),
3477                content_offset_y: renderer.content_offset_y(),
3478                content_offset_x: renderer.content_offset_x(),
3479                content_inset_bottom: renderer.content_inset_bottom(),
3480                content_inset_right: renderer.content_inset_right(),
3481                cell_width: renderer.cell_width(),
3482                cell_height: renderer.cell_height(),
3483                padding: renderer.window_padding(),
3484                status_bar_height: (status_bar_height + custom_status_bar_height)
3485                    * renderer.scale_factor(),
3486                scale_factor: renderer.scale_factor(),
3487            };
3488
3489            // Check if we have a pane manager with panes - this just checks without modifying
3490            // We use pane_count() > 0 instead of has_multiple_panes() because even with a
3491            // single pane in the manager (e.g., after closing one tmux split), we need to
3492            // render via the pane manager path since cells are in the pane's terminal,
3493            // not the main renderer buffer.
3494            let (has_pane_manager, pane_count) = self
3495                .tab_manager
3496                .active_tab()
3497                .and_then(|t| t.pane_manager.as_ref())
3498                .map(|pm| (pm.pane_count() > 0, pm.pane_count()))
3499                .unwrap_or((false, 0));
3500
3501            crate::debug_trace!(
3502                "RENDER",
3503                "has_pane_manager={}, pane_count={}",
3504                has_pane_manager,
3505                pane_count
3506            );
3507
3508            // Per-pane backgrounds only take effect when splits are active.
3509            // In single-pane mode, skip per-pane background lookup.
3510            let pane_0_bg: Option<crate::pane::PaneBackground> = None;
3511
3512            let render_result = if has_pane_manager {
3513                // When splits are active and hide_window_padding_on_split is enabled,
3514                // use 0 padding so panes extend to the window edges
3515                let effective_padding =
3516                    if pane_count > 1 && self.config.hide_window_padding_on_split {
3517                        0.0
3518                    } else {
3519                        sizing.padding
3520                    };
3521
3522                // Render panes from pane manager - inline data gathering to avoid borrow conflicts
3523                let content_width = sizing.size.width as f32
3524                    - effective_padding * 2.0
3525                    - sizing.content_offset_x
3526                    - sizing.content_inset_right;
3527                let content_height = sizing.size.height as f32
3528                    - sizing.content_offset_y
3529                    - sizing.content_inset_bottom
3530                    - effective_padding
3531                    - sizing.status_bar_height;
3532
3533                // Gather all necessary data upfront while we can borrow tab_manager
3534                #[allow(clippy::type_complexity)]
3535                let pane_render_data: Option<(
3536                    Vec<PaneRenderData>,
3537                    Vec<crate::pane::DividerRect>,
3538                    Vec<PaneTitleInfo>,
3539                    Option<PaneViewport>,
3540                )> = {
3541                    let tab = self.tab_manager.active_tab_mut();
3542                    if let Some(tab) = tab {
3543                        if let Some(pm) = &mut tab.pane_manager {
3544                            // Update bounds
3545                            let bounds = crate::pane::PaneBounds::new(
3546                                effective_padding + sizing.content_offset_x,
3547                                sizing.content_offset_y,
3548                                content_width,
3549                                content_height,
3550                            );
3551                            pm.set_bounds(bounds);
3552
3553                            // Calculate title bar height offset for terminal sizing
3554                            // Scale from logical pixels (config) to physical pixels
3555                            let title_height_offset = if self.config.show_pane_titles {
3556                                self.config.pane_title_height * sizing.scale_factor
3557                            } else {
3558                                0.0
3559                            };
3560
3561                            // Resize all pane terminals to match their new bounds
3562                            // Scale pane_padding from logical to physical pixels
3563                            pm.resize_all_terminals_with_padding(
3564                                sizing.cell_width,
3565                                sizing.cell_height,
3566                                effective_pane_padding * sizing.scale_factor,
3567                                title_height_offset,
3568                            );
3569
3570                            // Gather pane info
3571                            let focused_pane_id = pm.focused_pane_id();
3572                            let all_pane_ids: Vec<_> =
3573                                pm.all_panes().iter().map(|p| p.id).collect();
3574                            let dividers = pm.get_dividers();
3575
3576                            let pane_bg_opacity = self.config.pane_background_opacity;
3577                            let inactive_opacity = if self.config.dim_inactive_panes {
3578                                self.config.inactive_pane_opacity
3579                            } else {
3580                                1.0
3581                            };
3582                            let cursor_opacity = self.cursor_opacity;
3583
3584                            // Pane title settings
3585                            // Scale from logical pixels (config) to physical pixels
3586                            let show_titles = self.config.show_pane_titles;
3587                            let title_height = self.config.pane_title_height * sizing.scale_factor;
3588                            let title_position = self.config.pane_title_position;
3589                            let title_text_color = [
3590                                self.config.pane_title_color[0] as f32 / 255.0,
3591                                self.config.pane_title_color[1] as f32 / 255.0,
3592                                self.config.pane_title_color[2] as f32 / 255.0,
3593                            ];
3594                            let title_bg_color = [
3595                                self.config.pane_title_bg_color[0] as f32 / 255.0,
3596                                self.config.pane_title_bg_color[1] as f32 / 255.0,
3597                                self.config.pane_title_bg_color[2] as f32 / 255.0,
3598                            ];
3599
3600                            let mut pane_data = Vec::new();
3601                            let mut pane_titles = Vec::new();
3602                            let mut focused_viewport: Option<PaneViewport> = None;
3603
3604                            for pane_id in &all_pane_ids {
3605                                if let Some(pane) = pm.get_pane(*pane_id) {
3606                                    let is_focused = Some(*pane_id) == focused_pane_id;
3607                                    let bounds = pane.bounds;
3608
3609                                    // Calculate viewport, adjusting for title bar if shown
3610                                    let (viewport_y, viewport_height) = if show_titles {
3611                                        use crate::config::PaneTitlePosition;
3612                                        match title_position {
3613                                            PaneTitlePosition::Top => (
3614                                                bounds.y + title_height,
3615                                                (bounds.height - title_height).max(0.0),
3616                                            ),
3617                                            PaneTitlePosition::Bottom => {
3618                                                (bounds.y, (bounds.height - title_height).max(0.0))
3619                                            }
3620                                        }
3621                                    } else {
3622                                        (bounds.y, bounds.height)
3623                                    };
3624
3625                                    // Create viewport with padding for content inset
3626                                    // Scale pane_padding from logical to physical pixels
3627                                    let physical_pane_padding =
3628                                        effective_pane_padding * sizing.scale_factor;
3629                                    let viewport = PaneViewport::with_padding(
3630                                        bounds.x,
3631                                        viewport_y,
3632                                        bounds.width,
3633                                        viewport_height,
3634                                        is_focused,
3635                                        if is_focused {
3636                                            pane_bg_opacity
3637                                        } else {
3638                                            pane_bg_opacity * inactive_opacity
3639                                        },
3640                                        physical_pane_padding,
3641                                    );
3642
3643                                    if is_focused {
3644                                        focused_viewport = Some(viewport);
3645                                    }
3646
3647                                    // Build pane title info
3648                                    if show_titles {
3649                                        use crate::config::PaneTitlePosition;
3650                                        let title_y = match title_position {
3651                                            PaneTitlePosition::Top => bounds.y,
3652                                            PaneTitlePosition::Bottom => {
3653                                                bounds.y + bounds.height - title_height
3654                                            }
3655                                        };
3656                                        pane_titles.push(PaneTitleInfo {
3657                                            x: bounds.x,
3658                                            y: title_y,
3659                                            width: bounds.width,
3660                                            height: title_height,
3661                                            title: pane.get_title(),
3662                                            focused: is_focused,
3663                                            text_color: title_text_color,
3664                                            bg_color: title_bg_color,
3665                                        });
3666                                    }
3667
3668                                    let cells = if let Ok(term) = pane.terminal.try_lock() {
3669                                        let scroll_offset = pane.scroll_state.offset;
3670                                        let selection =
3671                                            pane.mouse.selection.map(|sel| sel.normalized());
3672                                        let rectangular = pane
3673                                            .mouse
3674                                            .selection
3675                                            .map(|sel| sel.mode == SelectionMode::Rectangular)
3676                                            .unwrap_or(false);
3677                                        term.get_cells_with_scrollback(
3678                                            scroll_offset,
3679                                            selection,
3680                                            rectangular,
3681                                            None,
3682                                        )
3683                                    } else {
3684                                        Vec::new()
3685                                    };
3686
3687                                    let need_marks = self.config.scrollbar_command_marks
3688                                        || self.config.command_separator_enabled;
3689                                    let (marks, pane_scrollback_len) = if need_marks {
3690                                        if let Ok(mut term) = pane.terminal.try_lock() {
3691                                            // Use cursor row 0 when unknown in split panes
3692                                            let sb_len = term.scrollback_len();
3693                                            term.update_scrollback_metadata(sb_len, 0);
3694                                            (term.scrollback_marks(), sb_len)
3695                                        } else {
3696                                            (Vec::new(), 0)
3697                                        }
3698                                    } else {
3699                                        (Vec::new(), 0)
3700                                    };
3701                                    let pane_scroll_offset = pane.scroll_state.offset;
3702
3703                                    // Per-pane backgrounds only apply when multiple panes exist
3704                                    let pane_background = if all_pane_ids.len() > 1
3705                                        && pane.background().has_image()
3706                                    {
3707                                        Some(pane.background().clone())
3708                                    } else {
3709                                        None
3710                                    };
3711
3712                                    let cursor_pos = if let Ok(term) = pane.terminal.try_lock() {
3713                                        if term.is_cursor_visible() {
3714                                            Some(term.cursor_position())
3715                                        } else {
3716                                            None
3717                                        }
3718                                    } else {
3719                                        None
3720                                    };
3721
3722                                    // Grid size must match the terminal's actual size
3723                                    // (accounting for padding and title bar, same as resize_all_terminals_with_padding)
3724                                    let content_width = (bounds.width
3725                                        - physical_pane_padding * 2.0)
3726                                        .max(sizing.cell_width);
3727                                    let content_height = (viewport_height
3728                                        - physical_pane_padding * 2.0)
3729                                        .max(sizing.cell_height);
3730                                    let cols = (content_width / sizing.cell_width).floor() as usize;
3731                                    let rows =
3732                                        (content_height / sizing.cell_height).floor() as usize;
3733                                    let cols = cols.max(1);
3734                                    let rows = rows.max(1);
3735
3736                                    pane_data.push((
3737                                        viewport,
3738                                        cells,
3739                                        (cols, rows),
3740                                        cursor_pos,
3741                                        if is_focused { cursor_opacity } else { 0.0 },
3742                                        marks,
3743                                        pane_scrollback_len,
3744                                        pane_scroll_offset,
3745                                        pane_background,
3746                                    ));
3747                                }
3748                            }
3749
3750                            Some((pane_data, dividers, pane_titles, focused_viewport))
3751                        } else {
3752                            None
3753                        }
3754                    } else {
3755                        None
3756                    }
3757                };
3758
3759                if let Some((pane_data, dividers, pane_titles, focused_viewport)) = pane_render_data
3760                {
3761                    // Get hovered divider index for hover color rendering
3762                    let hovered_divider_index = self
3763                        .tab_manager
3764                        .active_tab()
3765                        .and_then(|t| t.mouse.hovered_divider_index);
3766
3767                    // Render split panes
3768                    Self::render_split_panes_with_data(
3769                        renderer,
3770                        pane_data,
3771                        dividers,
3772                        pane_titles,
3773                        focused_viewport,
3774                        &self.config,
3775                        egui_data,
3776                        hovered_divider_index,
3777                    )
3778                } else {
3779                    // Fallback to single pane render
3780                    renderer.render(egui_data, false, show_scrollbar, pane_0_bg.as_ref())
3781                }
3782            } else {
3783                // Single pane - use standard render path
3784                renderer.render(egui_data, false, show_scrollbar, pane_0_bg.as_ref())
3785            };
3786
3787            match render_result {
3788                Ok(rendered) => {
3789                    if !rendered {
3790                        log::trace!("Skipped rendering - no changes");
3791                    }
3792                }
3793                Err(e) => {
3794                    // Check if this is a wgpu surface error that requires reconfiguration
3795                    // This commonly happens when dragging windows between displays
3796                    if let Some(surface_error) = e.downcast_ref::<SurfaceError>() {
3797                        match surface_error {
3798                            SurfaceError::Outdated | SurfaceError::Lost => {
3799                                log::warn!(
3800                                    "Surface error detected ({:?}), reconfiguring...",
3801                                    surface_error
3802                                );
3803                                self.force_surface_reconfigure();
3804                            }
3805                            SurfaceError::Timeout => {
3806                                log::warn!("Surface timeout, will retry next frame");
3807                                if let Some(window) = &self.window {
3808                                    window.request_redraw();
3809                                }
3810                            }
3811                            SurfaceError::OutOfMemory => {
3812                                log::error!("Surface out of memory: {:?}", surface_error);
3813                            }
3814                            _ => {
3815                                log::error!("Surface error: {:?}", surface_error);
3816                            }
3817                        }
3818                    } else {
3819                        log::error!("Render error: {}", e);
3820                    }
3821                }
3822            }
3823            debug_actual_render_time = actual_render_start.elapsed();
3824            let _ = debug_actual_render_time;
3825
3826            self.debug.render_time = render_start.elapsed();
3827        }
3828
3829        // Sync AI Inspector panel width after the render pass.
3830        // This catches drag-resize changes that update self.ai_inspector.width during show().
3831        // Done here to avoid borrow conflicts with the renderer block above.
3832        self.sync_ai_inspector_width();
3833
3834        // Handle tab bar actions collected during egui rendering
3835        // (done here to avoid borrow conflicts with renderer)
3836        match pending_tab_action {
3837            TabBarAction::SwitchTo(id) => {
3838                self.tab_manager.switch_to(id);
3839                // Clear renderer cells and invalidate cache to ensure clean switch
3840                if let Some(renderer) = &mut self.renderer {
3841                    renderer.clear_all_cells();
3842                }
3843                if let Some(tab) = self.tab_manager.active_tab_mut() {
3844                    tab.cache.cells = None;
3845                }
3846                self.needs_redraw = true;
3847                if let Some(window) = &self.window {
3848                    window.request_redraw();
3849                }
3850            }
3851            TabBarAction::Close(id) => {
3852                // Switch to the tab first so close_current_tab() operates on it.
3853                // This routes through the full close path: running-jobs confirmation,
3854                // session undo capture, and preserve-shell logic.
3855                self.tab_manager.switch_to(id);
3856                let was_last = self.close_current_tab();
3857                if was_last {
3858                    self.is_shutting_down = true;
3859                }
3860                if let Some(window) = &self.window {
3861                    window.request_redraw();
3862                }
3863            }
3864            TabBarAction::NewTab => {
3865                self.new_tab();
3866                if let Some(window) = &self.window {
3867                    window.request_redraw();
3868                }
3869            }
3870            TabBarAction::SetColor(id, color) => {
3871                if let Some(tab) = self.tab_manager.get_tab_mut(id) {
3872                    tab.set_custom_color(color);
3873                    log::info!(
3874                        "Set custom color for tab {}: RGB({}, {}, {})",
3875                        id,
3876                        color[0],
3877                        color[1],
3878                        color[2]
3879                    );
3880                }
3881                if let Some(window) = &self.window {
3882                    window.request_redraw();
3883                }
3884            }
3885            TabBarAction::ClearColor(id) => {
3886                if let Some(tab) = self.tab_manager.get_tab_mut(id) {
3887                    tab.clear_custom_color();
3888                    log::info!("Cleared custom color for tab {}", id);
3889                }
3890                if let Some(window) = &self.window {
3891                    window.request_redraw();
3892                }
3893            }
3894            TabBarAction::Reorder(id, target_index) => {
3895                if self.tab_manager.move_tab_to_index(id, target_index) {
3896                    self.needs_redraw = true;
3897                    if let Some(window) = &self.window {
3898                        window.request_redraw();
3899                    }
3900                }
3901            }
3902            TabBarAction::NewTabWithProfile(profile_id) => {
3903                self.open_profile(profile_id);
3904                if let Some(window) = &self.window {
3905                    window.request_redraw();
3906                }
3907            }
3908            TabBarAction::Duplicate(id) => {
3909                self.duplicate_tab_by_id(id);
3910                if let Some(window) = &self.window {
3911                    window.request_redraw();
3912                }
3913            }
3914            TabBarAction::ToggleAssistantPanel => {
3915                let just_opened = self.ai_inspector.toggle();
3916                self.sync_ai_inspector_width();
3917                if just_opened {
3918                    self.try_auto_connect_agent();
3919                }
3920                if let Some(window) = &self.window {
3921                    window.request_redraw();
3922                }
3923            }
3924            TabBarAction::None => {}
3925        }
3926
3927        // Handle clipboard actions collected during egui rendering
3928        // (done here to avoid borrow conflicts with renderer)
3929        match pending_clipboard_action {
3930            ClipboardHistoryAction::Paste(content) => {
3931                self.paste_text(&content);
3932            }
3933            ClipboardHistoryAction::ClearAll => {
3934                if let Some(tab) = self.tab_manager.active_tab()
3935                    && let Ok(term) = tab.terminal.try_lock()
3936                {
3937                    term.clear_all_clipboard_history();
3938                    log::info!("Cleared all clipboard history");
3939                }
3940                self.clipboard_history_ui.update_entries(Vec::new());
3941            }
3942            ClipboardHistoryAction::ClearSlot(slot) => {
3943                if let Some(tab) = self.tab_manager.active_tab()
3944                    && let Ok(term) = tab.terminal.try_lock()
3945                {
3946                    term.clear_clipboard_history(slot);
3947                    log::info!("Cleared clipboard history for slot {:?}", slot);
3948                }
3949            }
3950            ClipboardHistoryAction::None => {}
3951        }
3952
3953        // Handle command history actions collected during egui rendering
3954        match pending_command_history_action {
3955            CommandHistoryAction::Insert(command) => {
3956                self.paste_text(&command);
3957                log::info!(
3958                    "Inserted command from history: {}",
3959                    &command[..command.len().min(60)]
3960                );
3961            }
3962            CommandHistoryAction::None => {}
3963        }
3964
3965        // Handle close confirmation dialog actions
3966        match pending_close_confirm_action {
3967            CloseConfirmAction::Close { tab_id, pane_id } => {
3968                // User confirmed close - close the tab/pane
3969                if let Some(pane_id) = pane_id {
3970                    // Close specific pane
3971                    if let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
3972                        && let Some(pm) = tab.pane_manager_mut()
3973                    {
3974                        pm.close_pane(pane_id);
3975                        log::info!("Force-closed pane {} in tab {}", pane_id, tab_id);
3976                    }
3977                } else {
3978                    // Close entire tab
3979                    self.tab_manager.close_tab(tab_id);
3980                    log::info!("Force-closed tab {}", tab_id);
3981                }
3982                self.needs_redraw = true;
3983                if let Some(window) = &self.window {
3984                    window.request_redraw();
3985                }
3986            }
3987            CloseConfirmAction::Cancel => {
3988                // User cancelled - do nothing, dialog already hidden
3989                log::debug!("Close confirmation cancelled");
3990            }
3991            CloseConfirmAction::None => {}
3992        }
3993
3994        // Handle quit confirmation dialog actions
3995        match pending_quit_confirm_action {
3996            QuitConfirmAction::Quit => {
3997                // User confirmed quit - proceed with shutdown
3998                log::info!("Quit confirmed by user");
3999                self.perform_shutdown();
4000            }
4001            QuitConfirmAction::Cancel => {
4002                log::debug!("Quit confirmation cancelled");
4003            }
4004            QuitConfirmAction::None => {}
4005        }
4006
4007        // Handle remote shell integration install action
4008        match pending_remote_install_action {
4009            RemoteShellInstallAction::Install => {
4010                // Send the install command via paste_text() which uses the same
4011                // code path as Cmd+V paste — handles bracketed paste mode and
4012                // correctly forwards through SSH sessions.
4013                let command = RemoteShellInstallUI::install_command();
4014                // paste_text appends \r internally via term.paste()
4015                self.paste_text(&format!("{}\n", command));
4016                if let Some(window) = &self.window {
4017                    window.request_redraw();
4018                }
4019            }
4020            RemoteShellInstallAction::Cancel => {
4021                if let Some(window) = &self.window {
4022                    window.request_redraw();
4023                }
4024            }
4025            RemoteShellInstallAction::None => {}
4026        }
4027
4028        // Handle SSH Quick Connect actions
4029        match pending_ssh_connect_action {
4030            SshConnectAction::Connect {
4031                host,
4032                profile_override: _,
4033            } => {
4034                // Build SSH command and write it to the active terminal's PTY
4035                let args = host.ssh_args();
4036                let ssh_cmd = format!("ssh {}\n", args.join(" "));
4037                if let Some(tab) = self.tab_manager.active_tab()
4038                    && let Ok(term) = tab.terminal.try_lock()
4039                {
4040                    let _ = term.write_str(&ssh_cmd);
4041                }
4042                log::info!(
4043                    "SSH Quick Connect: connecting to {}",
4044                    host.connection_string()
4045                );
4046                if let Some(window) = &self.window {
4047                    window.request_redraw();
4048                }
4049            }
4050            SshConnectAction::Cancel => {
4051                if let Some(window) = &self.window {
4052                    window.request_redraw();
4053                }
4054            }
4055            SshConnectAction::None => {}
4056        }
4057
4058        // Handle paste special actions collected during egui rendering
4059        match pending_paste_special_action {
4060            PasteSpecialAction::Paste(content) => {
4061                self.paste_text(&content);
4062                log::debug!("Pasted transformed text ({} chars)", content.len());
4063            }
4064            PasteSpecialAction::None => {}
4065        }
4066
4067        // Handle search actions collected during egui rendering
4068        match pending_search_action {
4069            crate::search::SearchAction::ScrollToMatch(offset) => {
4070                self.set_scroll_target(offset);
4071                self.needs_redraw = true;
4072                if let Some(window) = &self.window {
4073                    window.request_redraw();
4074                }
4075            }
4076            crate::search::SearchAction::Close => {
4077                self.needs_redraw = true;
4078                if let Some(window) = &self.window {
4079                    window.request_redraw();
4080                }
4081            }
4082            crate::search::SearchAction::None => {}
4083        }
4084
4085        // Handle AI Inspector actions collected during egui rendering
4086        match pending_inspector_action {
4087            InspectorAction::Close => {
4088                self.ai_inspector.open = false;
4089                self.sync_ai_inspector_width();
4090            }
4091            InspectorAction::CopyJson(json) => {
4092                if let Ok(mut clipboard) = arboard::Clipboard::new() {
4093                    let _ = clipboard.set_text(json);
4094                }
4095            }
4096            InspectorAction::SaveToFile(json) => {
4097                if let Some(path) = rfd::FileDialog::new()
4098                    .set_file_name(format!(
4099                        "par-term-snapshot-{}.json",
4100                        chrono::Local::now().format("%Y-%m-%d-%H%M%S")
4101                    ))
4102                    .add_filter("JSON", &["json"])
4103                    .save_file()
4104                {
4105                    let _ = std::fs::write(path, json);
4106                }
4107            }
4108            InspectorAction::WriteToTerminal(cmd) => {
4109                if let Some(tab) = self.tab_manager.active_tab()
4110                    && let Ok(term) = tab.terminal.try_lock()
4111                {
4112                    let _ = term.write(cmd.as_bytes());
4113                }
4114            }
4115            InspectorAction::RunCommandAndNotify(cmd) => {
4116                // Write command + Enter to terminal
4117                if let Some(tab) = self.tab_manager.active_tab()
4118                    && let Ok(term) = tab.terminal.try_lock()
4119                {
4120                    let _ = term.write(format!("{cmd}\n").as_bytes());
4121                }
4122                // Record command count before execution so we can detect completion
4123                let history_len = self
4124                    .tab_manager
4125                    .active_tab()
4126                    .and_then(|tab| tab.terminal.try_lock().ok())
4127                    .map(|term| term.core_command_history().len())
4128                    .unwrap_or(0);
4129                // Spawn a task that polls for command completion and notifies the agent
4130                if let Some(agent) = &self.agent {
4131                    let agent = agent.clone();
4132                    let tx = self.agent_tx.clone();
4133                    let terminal = self
4134                        .tab_manager
4135                        .active_tab()
4136                        .map(|tab| tab.terminal.clone());
4137                    let cmd_for_msg = cmd.clone();
4138                    self.runtime.spawn(async move {
4139                        // Poll for command completion (up to 30 seconds)
4140                        let mut exit_code: Option<i32> = None;
4141                        for _ in 0..300 {
4142                            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4143                            if let Some(ref terminal) = terminal
4144                                && let Ok(term) = terminal.try_lock()
4145                            {
4146                                let history = term.core_command_history();
4147                                if history.len() > history_len {
4148                                    // New command finished
4149                                    if let Some(last) = history.last() {
4150                                        exit_code = last.1;
4151                                    }
4152                                    break;
4153                                }
4154                            }
4155                        }
4156                        // Send feedback to agent
4157                        let exit_str = exit_code
4158                            .map(|c| format!("exit code {c}"))
4159                            .unwrap_or_else(|| "unknown exit code".to_string());
4160                        let feedback = format!(
4161                            "[System: The user executed `{cmd_for_msg}` in their terminal ({exit_str}). \
4162                             The output is available through the normal terminal capture.]"
4163                        );
4164                        let content = vec![par_term_acp::ContentBlock::Text {
4165                            text: feedback,
4166                        }];
4167                        let agent = agent.lock().await;
4168                        let _ = agent.send_prompt(content).await;
4169                        if let Some(tx) = tx {
4170                            let _ = tx.send(par_term_acp::AgentMessage::PromptComplete);
4171                        }
4172                    });
4173                }
4174                self.needs_redraw = true;
4175            }
4176            InspectorAction::ConnectAgent(identity) => {
4177                self.connect_agent(&identity);
4178            }
4179            InspectorAction::DisconnectAgent => {
4180                if let Some(agent) = self.agent.take() {
4181                    self.runtime.spawn(async move {
4182                        let mut agent = agent.lock().await;
4183                        agent.disconnect().await;
4184                    });
4185                }
4186                self.agent_rx = None;
4187                self.agent_tx = None;
4188                self.agent_client = None;
4189                self.ai_inspector.agent_status = AgentStatus::Disconnected;
4190                self.needs_redraw = true;
4191            }
4192            InspectorAction::SendPrompt(text) => {
4193                self.ai_inspector.chat.add_user_message(text.clone());
4194                if let Some(agent) = &self.agent {
4195                    let agent = agent.clone();
4196                    // Build the prompt with optional system guidance and shader context.
4197                    let mut prompt_text = String::new();
4198
4199                    // Prepend system guidance on the first prompt so the agent
4200                    // knows to wrap commands in fenced code blocks.
4201                    if !self.ai_inspector.chat.system_prompt_sent {
4202                        self.ai_inspector.chat.system_prompt_sent = true;
4203                        prompt_text.push_str(crate::ai_inspector::chat::AGENT_SYSTEM_GUIDANCE);
4204                    }
4205
4206                    // Inject shader context when relevant (keyword match or active shaders).
4207                    if crate::ai_inspector::shader_context::should_inject_shader_context(
4208                        &text,
4209                        &self.config,
4210                    ) {
4211                        prompt_text.push_str(
4212                            &crate::ai_inspector::shader_context::build_shader_context(
4213                                &self.config,
4214                            ),
4215                        );
4216                    }
4217
4218                    prompt_text.push_str(&text);
4219
4220                    let content = vec![par_term_acp::ContentBlock::Text { text: prompt_text }];
4221                    let tx = self.agent_tx.clone();
4222                    self.runtime.spawn(async move {
4223                        let agent = agent.lock().await;
4224                        let _ = agent.send_prompt(content).await;
4225                        // Signal the UI to flush the agent text buffer so
4226                        // command suggestions are extracted.
4227                        if let Some(tx) = tx {
4228                            let _ = tx.send(AgentMessage::PromptComplete);
4229                        }
4230                    });
4231                }
4232                self.needs_redraw = true;
4233            }
4234            InspectorAction::SetTerminalAccess(enabled) => {
4235                self.config.ai_inspector_agent_terminal_access = enabled;
4236                self.needs_redraw = true;
4237            }
4238            InspectorAction::RespondPermission {
4239                request_id,
4240                option_id,
4241                cancelled,
4242            } => {
4243                if let Some(client) = &self.agent_client {
4244                    let client = client.clone();
4245                    let action = if cancelled { "cancelled" } else { "selected" };
4246                    log::info!("ACP: sending permission response id={request_id} action={action}");
4247                    self.runtime.spawn(async move {
4248                        use par_term_acp::{PermissionOutcome, RequestPermissionResponse};
4249                        let outcome = if cancelled {
4250                            PermissionOutcome {
4251                                outcome: "cancelled".to_string(),
4252                                option_id: None,
4253                            }
4254                        } else {
4255                            PermissionOutcome {
4256                                outcome: "selected".to_string(),
4257                                option_id: Some(option_id),
4258                            }
4259                        };
4260                        let result = RequestPermissionResponse { outcome };
4261                        if let Err(e) = client
4262                            .respond(
4263                                request_id,
4264                                Some(serde_json::to_value(&result).unwrap()),
4265                                None,
4266                            )
4267                            .await
4268                        {
4269                            log::error!("ACP: failed to send permission response: {e}");
4270                        }
4271                    });
4272                } else {
4273                    log::error!(
4274                        "ACP: cannot send permission response id={request_id} — agent_client is None!"
4275                    );
4276                }
4277                // Mark the permission as resolved in the chat.
4278                for msg in &mut self.ai_inspector.chat.messages {
4279                    if let ChatMessage::Permission {
4280                        request_id: rid,
4281                        resolved,
4282                        ..
4283                    } = msg
4284                        && *rid == request_id
4285                    {
4286                        *resolved = true;
4287                        break;
4288                    }
4289                }
4290                self.needs_redraw = true;
4291            }
4292            InspectorAction::SetAgentMode(mode_id) => {
4293                let is_yolo = mode_id == "bypassPermissions";
4294                self.config.ai_inspector_auto_approve = is_yolo;
4295                if let Some(agent) = &self.agent {
4296                    let agent = agent.clone();
4297                    self.runtime.spawn(async move {
4298                        let agent = agent.lock().await;
4299                        agent
4300                            .auto_approve
4301                            .store(is_yolo, std::sync::atomic::Ordering::Relaxed);
4302                        if let Err(e) = agent.set_mode(&mode_id).await {
4303                            log::error!("ACP: failed to set mode '{mode_id}': {e}");
4304                        }
4305                    });
4306                }
4307                self.needs_redraw = true;
4308            }
4309            InspectorAction::None => {}
4310        }
4311
4312        // Handle tmux session picker actions collected during egui rendering
4313        // Uses gateway mode: writes tmux commands to existing PTY instead of spawning process
4314        match pending_session_picker_action {
4315            SessionPickerAction::Attach(session_name) => {
4316                crate::debug_info!(
4317                    "TMUX",
4318                    "Session picker: attaching to '{}' via gateway",
4319                    session_name
4320                );
4321                if let Err(e) = self.attach_tmux_gateway(&session_name) {
4322                    log::error!("Failed to attach to tmux session '{}': {}", session_name, e);
4323                    self.show_toast(format!("Failed to attach: {}", e));
4324                } else {
4325                    crate::debug_info!("TMUX", "Gateway initiated for session '{}'", session_name);
4326                    self.show_toast(format!("Connecting to session '{}'...", session_name));
4327                }
4328                self.needs_redraw = true;
4329            }
4330            SessionPickerAction::CreateNew(name) => {
4331                crate::debug_info!(
4332                    "TMUX",
4333                    "Session picker: creating new session {:?} via gateway",
4334                    name
4335                );
4336                if let Err(e) = self.initiate_tmux_gateway(name.as_deref()) {
4337                    log::error!("Failed to create tmux session: {}", e);
4338                    crate::debug_error!("TMUX", "Failed to initiate gateway: {}", e);
4339                    self.show_toast(format!("Failed to create session: {}", e));
4340                } else {
4341                    let msg = match name {
4342                        Some(ref n) => format!("Creating session '{}'...", n),
4343                        None => "Creating new tmux session...".to_string(),
4344                    };
4345                    crate::debug_info!("TMUX", "Gateway initiated: {}", msg);
4346                    self.show_toast(msg);
4347                }
4348                self.needs_redraw = true;
4349            }
4350            SessionPickerAction::None => {}
4351        }
4352
4353        // Check for shader installation completion from background thread
4354        if let Some(ref rx) = self.shader_install_receiver
4355            && let Ok(result) = rx.try_recv()
4356        {
4357            match result {
4358                Ok(count) => {
4359                    log::info!("Successfully installed {} shaders", count);
4360                    self.shader_install_ui
4361                        .set_success(&format!("Installed {} shaders!", count));
4362
4363                    // Update config to mark as installed
4364                    self.config.shader_install_prompt = ShaderInstallPrompt::Installed;
4365                    if let Err(e) = self.config.save() {
4366                        log::error!("Failed to save config after shader install: {}", e);
4367                    }
4368                }
4369                Err(e) => {
4370                    log::error!("Failed to install shaders: {}", e);
4371                    self.shader_install_ui.set_error(&e);
4372                }
4373            }
4374            self.shader_install_receiver = None;
4375            self.needs_redraw = true;
4376        }
4377
4378        // Handle shader install responses
4379        match pending_shader_install_response {
4380            ShaderInstallResponse::Install => {
4381                log::info!("User requested shader installation");
4382                self.shader_install_ui
4383                    .set_installing("Downloading shaders...");
4384                self.needs_redraw = true;
4385
4386                // Spawn installation in background thread so UI can show progress
4387                let (tx, rx) = std::sync::mpsc::channel();
4388                self.shader_install_receiver = Some(rx);
4389
4390                std::thread::spawn(move || {
4391                    let result = crate::shader_install_ui::install_shaders_headless();
4392                    let _ = tx.send(result);
4393                });
4394
4395                // Request redraw so the spinner shows
4396                if let Some(window) = &self.window {
4397                    window.request_redraw();
4398                }
4399            }
4400            ShaderInstallResponse::Never => {
4401                log::info!("User declined shader installation (never ask again)");
4402                self.shader_install_ui.hide();
4403
4404                // Update config to never ask again
4405                self.config.shader_install_prompt = ShaderInstallPrompt::Never;
4406                if let Err(e) = self.config.save() {
4407                    log::error!("Failed to save config after declining shaders: {}", e);
4408                }
4409            }
4410            ShaderInstallResponse::Later => {
4411                log::info!("User deferred shader installation");
4412                self.shader_install_ui.hide();
4413                // Config remains "ask" - will prompt again on next startup
4414            }
4415            ShaderInstallResponse::None => {}
4416        }
4417
4418        // Handle integrations welcome dialog responses
4419        self.handle_integrations_response(&pending_integrations_response);
4420
4421        // Handle profile drawer actions
4422        match pending_profile_drawer_action {
4423            ProfileDrawerAction::OpenProfile(id) => {
4424                self.open_profile(id);
4425            }
4426            ProfileDrawerAction::ManageProfiles => {
4427                // Open settings window to Profiles tab instead of terminal-embedded modal
4428                self.open_settings_window_requested = true;
4429                self.open_settings_profiles_tab = true;
4430            }
4431            ProfileDrawerAction::None => {}
4432        }
4433
4434        let absolute_total = absolute_start.elapsed();
4435        if absolute_total.as_millis() > 10 {
4436            log::debug!(
4437                "TIMING: AbsoluteTotal={:.2}ms (from function start to end)",
4438                absolute_total.as_secs_f64() * 1000.0
4439            );
4440        }
4441    }
4442
4443    /// Render split panes when the active tab has multiple panes
4444    #[allow(clippy::too_many_arguments)]
4445    fn render_split_panes_with_data(
4446        renderer: &mut Renderer,
4447        pane_data: Vec<PaneRenderData>,
4448        dividers: Vec<crate::pane::DividerRect>,
4449        pane_titles: Vec<PaneTitleInfo>,
4450        focused_viewport: Option<PaneViewport>,
4451        config: &Config,
4452        egui_data: Option<(egui::FullOutput, &egui::Context)>,
4453        hovered_divider_index: Option<usize>,
4454    ) -> Result<bool> {
4455        // Build pane render infos - we need to leak the cells temporarily
4456        let mut pane_render_infos: Vec<PaneRenderInfo> = Vec::new();
4457        let mut leaked_cells: Vec<*mut [crate::cell_renderer::Cell]> = Vec::new();
4458
4459        for (
4460            viewport,
4461            cells,
4462            grid_size,
4463            cursor_pos,
4464            cursor_opacity,
4465            marks,
4466            scrollback_len,
4467            scroll_offset,
4468            pane_background,
4469        ) in pane_data
4470        {
4471            let cells_boxed = cells.into_boxed_slice();
4472            let cells_ptr = Box::into_raw(cells_boxed);
4473            leaked_cells.push(cells_ptr);
4474
4475            pane_render_infos.push(PaneRenderInfo {
4476                viewport,
4477                // SAFETY: We just allocated this, and we'll free it after rendering
4478                cells: unsafe { &*cells_ptr },
4479                grid_size,
4480                cursor_pos,
4481                cursor_opacity,
4482                show_scrollbar: false,
4483                marks,
4484                scrollback_len,
4485                scroll_offset,
4486                background: pane_background,
4487            });
4488        }
4489
4490        // Build divider render info
4491        let divider_render_infos: Vec<DividerRenderInfo> = dividers
4492            .iter()
4493            .enumerate()
4494            .map(|(i, d)| DividerRenderInfo::from_rect(d, hovered_divider_index == Some(i)))
4495            .collect();
4496
4497        // Build divider settings from config
4498        let divider_settings = PaneDividerSettings {
4499            divider_color: [
4500                config.pane_divider_color[0] as f32 / 255.0,
4501                config.pane_divider_color[1] as f32 / 255.0,
4502                config.pane_divider_color[2] as f32 / 255.0,
4503            ],
4504            hover_color: [
4505                config.pane_divider_hover_color[0] as f32 / 255.0,
4506                config.pane_divider_hover_color[1] as f32 / 255.0,
4507                config.pane_divider_hover_color[2] as f32 / 255.0,
4508            ],
4509            show_focus_indicator: config.pane_focus_indicator,
4510            focus_color: [
4511                config.pane_focus_color[0] as f32 / 255.0,
4512                config.pane_focus_color[1] as f32 / 255.0,
4513                config.pane_focus_color[2] as f32 / 255.0,
4514            ],
4515            focus_width: config.pane_focus_width * renderer.scale_factor(),
4516            divider_style: config.pane_divider_style,
4517        };
4518
4519        // Call the split pane renderer
4520        let result = renderer.render_split_panes(
4521            &pane_render_infos,
4522            &divider_render_infos,
4523            &pane_titles,
4524            focused_viewport.as_ref(),
4525            &divider_settings,
4526            egui_data,
4527            false,
4528        );
4529
4530        // Clean up leaked cell memory
4531        for ptr in leaked_cells {
4532            // SAFETY: We just allocated these above
4533            let _ = unsafe { Box::from_raw(ptr) };
4534        }
4535
4536        result
4537    }
4538
4539    /// Handle responses from the integrations welcome dialog
4540    fn handle_integrations_response(&mut self, response: &IntegrationsResponse) {
4541        // Nothing to do if dialog wasn't interacted with
4542        if !response.install_shaders
4543            && !response.install_shell_integration
4544            && !response.skipped
4545            && !response.never_ask
4546            && !response.closed
4547            && response.shader_conflict_action.is_none()
4548        {
4549            return;
4550        }
4551
4552        let current_version = env!("CARGO_PKG_VERSION").to_string();
4553
4554        // Determine install intent and overwrite behavior
4555        let mut install_shaders = false;
4556        let mut install_shell_integration = false;
4557        let mut force_overwrite_modified_shaders = false;
4558        let mut triggered_install = false;
4559
4560        // If we're waiting on a shader overwrite decision, handle that first
4561        if let Some(action) = response.shader_conflict_action {
4562            triggered_install = true;
4563            install_shaders = self.integrations_ui.pending_install_shaders;
4564            install_shell_integration = self.integrations_ui.pending_install_shell_integration;
4565
4566            match action {
4567                crate::integrations_ui::ShaderConflictAction::Overwrite => {
4568                    force_overwrite_modified_shaders = true;
4569                }
4570                crate::integrations_ui::ShaderConflictAction::SkipModified => {
4571                    force_overwrite_modified_shaders = false;
4572                }
4573                crate::integrations_ui::ShaderConflictAction::Cancel => {
4574                    // Reset pending state and exit without installing
4575                    self.integrations_ui.awaiting_shader_overwrite = false;
4576                    self.integrations_ui.shader_conflicts.clear();
4577                    self.integrations_ui.pending_install_shaders = false;
4578                    self.integrations_ui.pending_install_shell_integration = false;
4579                    self.integrations_ui.error_message = None;
4580                    self.integrations_ui.success_message = None;
4581                    self.needs_redraw = true;
4582                    return;
4583                }
4584            }
4585
4586            // Clear the conflict prompt regardless of choice
4587            self.integrations_ui.awaiting_shader_overwrite = false;
4588            self.integrations_ui.shader_conflicts.clear();
4589            self.integrations_ui.error_message = None;
4590            self.integrations_ui.success_message = None;
4591            self.integrations_ui.installing = false;
4592        } else if response.install_shaders || response.install_shell_integration {
4593            triggered_install = true;
4594            install_shaders = response.install_shaders;
4595            install_shell_integration = response.install_shell_integration;
4596
4597            if install_shaders {
4598                match crate::shader_installer::detect_modified_bundled_shaders() {
4599                    Ok(conflicts) if !conflicts.is_empty() => {
4600                        log::info!(
4601                            "Detected {} modified bundled shaders; prompting for overwrite",
4602                            conflicts.len()
4603                        );
4604                        self.integrations_ui.awaiting_shader_overwrite = true;
4605                        self.integrations_ui.shader_conflicts = conflicts;
4606                        self.integrations_ui.pending_install_shaders = install_shaders;
4607                        self.integrations_ui.pending_install_shell_integration =
4608                            install_shell_integration;
4609                        self.integrations_ui.installing = false;
4610                        self.integrations_ui.error_message = None;
4611                        self.integrations_ui.success_message = None;
4612                        self.needs_redraw = true;
4613                        return; // Wait for user decision
4614                    }
4615                    Ok(_) => {}
4616                    Err(e) => {
4617                        log::warn!(
4618                            "Unable to check existing shaders for modifications: {}. Proceeding without overwrite prompt.",
4619                            e
4620                        );
4621                    }
4622                }
4623            }
4624        }
4625
4626        // Handle "Install Selected" - user wants to install one or both integrations
4627        if triggered_install {
4628            log::info!(
4629                "User requested installations: shaders={}, shell_integration={}, overwrite_modified={}",
4630                install_shaders,
4631                install_shell_integration,
4632                force_overwrite_modified_shaders
4633            );
4634
4635            let mut success_parts = Vec::new();
4636            let mut error_parts = Vec::new();
4637
4638            // Install shaders if requested
4639            if install_shaders {
4640                self.integrations_ui.set_installing("Installing shaders...");
4641                self.needs_redraw = true;
4642                self.request_redraw();
4643
4644                match crate::shader_installer::install_shaders_with_manifest(
4645                    force_overwrite_modified_shaders,
4646                ) {
4647                    Ok(result) => {
4648                        log::info!(
4649                            "Installed {} shader files ({} skipped, {} removed)",
4650                            result.installed,
4651                            result.skipped,
4652                            result.removed
4653                        );
4654                        let detail = if result.skipped > 0 {
4655                            format!("{} shaders ({} skipped)", result.installed, result.skipped)
4656                        } else {
4657                            format!("{} shaders", result.installed)
4658                        };
4659                        success_parts.push(detail);
4660                        self.config.integration_versions.shaders_installed_version =
4661                            Some(current_version.clone());
4662                        self.config.integration_versions.shaders_prompted_version =
4663                            Some(current_version.clone());
4664                    }
4665                    Err(e) => {
4666                        log::error!("Failed to install shaders: {}", e);
4667                        error_parts.push(format!("Shaders: {}", e));
4668                    }
4669                }
4670            }
4671
4672            // Install shell integration if requested
4673            if install_shell_integration {
4674                self.integrations_ui
4675                    .set_installing("Installing shell integration...");
4676                self.needs_redraw = true;
4677                self.request_redraw();
4678
4679                match crate::shell_integration_installer::install(None) {
4680                    Ok(result) => {
4681                        log::info!(
4682                            "Installed shell integration for {}",
4683                            result.shell.display_name()
4684                        );
4685                        success_parts.push(format!(
4686                            "shell integration ({})",
4687                            result.shell.display_name()
4688                        ));
4689                        self.config
4690                            .integration_versions
4691                            .shell_integration_installed_version = Some(current_version.clone());
4692                        self.config
4693                            .integration_versions
4694                            .shell_integration_prompted_version = Some(current_version.clone());
4695                    }
4696                    Err(e) => {
4697                        log::error!("Failed to install shell integration: {}", e);
4698                        error_parts.push(format!("Shell: {}", e));
4699                    }
4700                }
4701            }
4702
4703            // Show result
4704            if error_parts.is_empty() {
4705                self.integrations_ui
4706                    .set_success(&format!("Installed: {}", success_parts.join(", ")));
4707            } else if success_parts.is_empty() {
4708                self.integrations_ui
4709                    .set_error(&format!("Installation failed: {}", error_parts.join("; ")));
4710            } else {
4711                // Partial success
4712                self.integrations_ui.set_success(&format!(
4713                    "Installed: {}. Errors: {}",
4714                    success_parts.join(", "),
4715                    error_parts.join("; ")
4716                ));
4717            }
4718
4719            // Save config
4720            if let Err(e) = self.config.save() {
4721                log::error!("Failed to save config after integration install: {}", e);
4722            }
4723
4724            // Clear pending flags
4725            self.integrations_ui.pending_install_shaders = false;
4726            self.integrations_ui.pending_install_shell_integration = false;
4727
4728            self.needs_redraw = true;
4729        }
4730
4731        // Handle "Skip" - just close the dialog for this session
4732        if response.skipped {
4733            log::info!("User skipped integrations dialog for this session");
4734            self.integrations_ui.hide();
4735            // Update prompted versions so we don't ask again this version
4736            self.config.integration_versions.shaders_prompted_version =
4737                Some(current_version.clone());
4738            self.config
4739                .integration_versions
4740                .shell_integration_prompted_version = Some(current_version.clone());
4741            if let Err(e) = self.config.save() {
4742                log::error!("Failed to save config after skipping integrations: {}", e);
4743            }
4744        }
4745
4746        // Handle "Never Ask" - disable prompting permanently
4747        if response.never_ask {
4748            log::info!("User declined integrations (never ask again)");
4749            self.integrations_ui.hide();
4750            // Set install prompts to Never
4751            self.config.shader_install_prompt = ShaderInstallPrompt::Never;
4752            self.config.shell_integration_state = crate::config::InstallPromptState::Never;
4753            if let Err(e) = self.config.save() {
4754                log::error!("Failed to save config after declining integrations: {}", e);
4755            }
4756        }
4757
4758        // Handle dialog closed (OK button after success)
4759        if response.closed {
4760            self.integrations_ui.hide();
4761        }
4762    }
4763
4764    /// Perform the shutdown sequence (save state and set shutdown flag)
4765    pub(crate) fn perform_shutdown(&mut self) {
4766        // Save last working directory for "previous session" mode
4767        if self.config.startup_directory_mode == crate::config::StartupDirectoryMode::Previous
4768            && let Some(tab) = self.tab_manager.active_tab()
4769            && let Ok(term) = tab.terminal.try_lock()
4770            && let Some(cwd) = term.shell_integration_cwd()
4771        {
4772            log::info!("Saving last working directory: {}", cwd);
4773            if let Err(e) = self.config.save_last_working_directory(&cwd) {
4774                log::warn!("Failed to save last working directory: {}", e);
4775            }
4776        }
4777
4778        // Set shutdown flag to stop redraw loop
4779        self.is_shutting_down = true;
4780        // Abort refresh tasks for all tabs
4781        for tab in self.tab_manager.tabs_mut() {
4782            if let Some(task) = tab.refresh_task.take() {
4783                task.abort();
4784            }
4785        }
4786        log::info!("Refresh tasks aborted, shutdown initiated");
4787    }
4788}
4789
4790// ---------------------------------------------------------------------------
4791impl Drop for WindowState {
4792    fn drop(&mut self) {
4793        let t0 = std::time::Instant::now();
4794        log::info!("Shutting down window (fast path)");
4795
4796        // Signal status bar polling threads to stop immediately.
4797        // They check the flag every 50ms, so by the time the auto-drop
4798        // calls join() later, the threads will already be exiting.
4799        self.status_bar_ui.signal_shutdown();
4800
4801        // Save command history on a background thread (serializes in-memory, writes async)
4802        self.command_history.save_background();
4803
4804        // Set shutdown flag
4805        self.is_shutting_down = true;
4806
4807        // Hide the window immediately for instant visual feedback
4808        if let Some(ref window) = self.window {
4809            window.set_visible(false);
4810            log::info!(
4811                "Window hidden for instant visual close (+{:.1}ms)",
4812                t0.elapsed().as_secs_f64() * 1000.0
4813            );
4814        }
4815
4816        // Clean up egui state FIRST before any other resources are dropped
4817        self.egui_state = None;
4818        self.egui_ctx = None;
4819
4820        // Drain all tabs from the manager (takes ownership without dropping)
4821        let mut tabs = self.tab_manager.drain_tabs();
4822        let tab_count = tabs.len();
4823        log::info!(
4824            "Fast shutdown: draining {} tabs (+{:.1}ms)",
4825            tab_count,
4826            t0.elapsed().as_secs_f64() * 1000.0
4827        );
4828
4829        // Collect terminal Arcs and session loggers from all tabs and panes
4830        // BEFORE setting shutdown_fast. Cloning the Arc keeps TerminalManager
4831        // alive even after Tab/Pane is dropped. Session loggers are collected
4832        // so they can be stopped on a background thread instead of blocking.
4833        let mut terminal_arcs = Vec::new();
4834        let mut session_loggers = Vec::new();
4835
4836        for tab in &mut tabs {
4837            // Stop refresh tasks (fast - just aborts tokio tasks)
4838            tab.stop_refresh_task();
4839
4840            // Collect session logger for background stop
4841            session_loggers.push(Arc::clone(&tab.session_logger));
4842
4843            // Clone terminal Arc before we mark shutdown_fast
4844            terminal_arcs.push(Arc::clone(&tab.terminal));
4845
4846            // Also handle panes if this tab has splits
4847            if let Some(ref mut pm) = tab.pane_manager {
4848                for pane in pm.all_panes_mut() {
4849                    pane.stop_refresh_task();
4850                    session_loggers.push(Arc::clone(&pane.session_logger));
4851                    terminal_arcs.push(Arc::clone(&pane.terminal));
4852                    pane.shutdown_fast = true;
4853                }
4854            }
4855
4856            // Mark tab for fast drop (skips sleep + kill in Tab::drop)
4857            tab.shutdown_fast = true;
4858        }
4859
4860        // Pre-kill all PTY processes (sends SIGKILL, fast non-blocking)
4861        for arc in &terminal_arcs {
4862            if let Ok(mut term) = arc.try_lock()
4863                && term.is_running()
4864            {
4865                let _ = term.kill();
4866            }
4867        }
4868        log::info!(
4869            "Pre-killed {} terminal sessions (+{:.1}ms)",
4870            terminal_arcs.len(),
4871            t0.elapsed().as_secs_f64() * 1000.0
4872        );
4873
4874        // Drop tabs on main thread (fast - Tab::drop just returns immediately)
4875        drop(tabs);
4876        log::info!(
4877            "Tabs dropped (+{:.1}ms)",
4878            t0.elapsed().as_secs_f64() * 1000.0
4879        );
4880
4881        // Fire-and-forget: stop session loggers on a background thread.
4882        // Each logger.stop() flushes buffered I/O which can block.
4883        if !session_loggers.is_empty() {
4884            let _ = std::thread::Builder::new()
4885                .name("logger-cleanup".into())
4886                .spawn(move || {
4887                    for logger_arc in session_loggers {
4888                        if let Some(ref mut logger) = *logger_arc.lock() {
4889                            let _ = logger.stop();
4890                        }
4891                    }
4892                });
4893        }
4894
4895        // Fire-and-forget: drop the cloned terminal Arcs on background threads.
4896        // When our clone is the last reference, TerminalManager::drop runs,
4897        // which triggers PtySession::drop (up to 2s reader thread wait).
4898        // By running these in parallel, all sessions clean up concurrently.
4899        // We intentionally do NOT join these threads — the process is exiting
4900        // and the OS will reclaim all resources.
4901        for (i, arc) in terminal_arcs.into_iter().enumerate() {
4902            let _ = std::thread::Builder::new()
4903                .name(format!("pty-cleanup-{}", i))
4904                .spawn(move || {
4905                    let t = std::time::Instant::now();
4906                    drop(arc);
4907                    log::info!(
4908                        "pty-cleanup-{} finished in {:.1}ms",
4909                        i,
4910                        t.elapsed().as_secs_f64() * 1000.0
4911                    );
4912                });
4913        }
4914
4915        log::info!(
4916            "Window shutdown complete ({} tabs, main thread blocked {:.1}ms)",
4917            tab_count,
4918            t0.elapsed().as_secs_f64() * 1000.0
4919        );
4920    }
4921}