Skip to main content

fresh/app/
render.rs

1use super::lsp_status::compose_lsp_status;
2use super::*;
3
4impl Editor {
5    /// Render the editor to the terminal
6    pub fn render(&mut self, frame: &mut Frame) {
7        let _span = tracing::info_span!("render").entered();
8        let size = frame.area();
9
10        // Save frame dimensions for recompute_layout (used by macro replay)
11        self.cached_layout.last_frame_width = size.width;
12        self.cached_layout.last_frame_height = size.height;
13
14        // Reset per-cell theme key map for this frame
15        self.cached_layout.reset_cell_theme_map();
16
17        // Attach any queued LSP auto-start prompt to the currently
18        // active buffer. Done here (rather than at file-open) so the
19        // popup follows the user's focus through a session restore
20        // that opens several files of the same language in
21        // succession. No-op when nothing is queued.
22        self.drain_pending_lsp_prompt_for_active_buffer();
23
24        // For scroll sync groups, we need to update the active split's viewport position BEFORE
25        // calling sync_scroll_groups, so that the sync reads the correct position.
26        // Otherwise, cursor movements like 'G' (go to end) won't sync properly because
27        // viewport.top_byte hasn't been updated yet.
28        let active_split = self.split_manager.active_split();
29        {
30            let _span = tracing::info_span!("pre_sync_ensure_visible").entered();
31            self.pre_sync_ensure_visible(active_split);
32        }
33
34        // Synchronize scroll sync groups (anchor-based scroll for side-by-side diffs)
35        // This sets viewport positions based on the authoritative scroll_line in each group
36        {
37            let _span = tracing::info_span!("sync_scroll_groups").entered();
38            self.sync_scroll_groups();
39        }
40
41        // NOTE: Viewport sync with cursor is handled by split_rendering.rs which knows the
42        // correct content area dimensions. Don't sync here with incorrect EditorState viewport size.
43
44        // Prepare all buffers for rendering (pre-load viewport data for lazy loading)
45        // Each split may have a different viewport position on the same buffer
46        let mut semantic_ranges: std::collections::HashMap<BufferId, (usize, usize)> =
47            std::collections::HashMap::new();
48        {
49            let _span = tracing::info_span!("compute_semantic_ranges").entered();
50            for (split_id, view_state) in &self.split_view_states {
51                if let Some(buffer_id) = self.split_manager.get_buffer_id((*split_id).into()) {
52                    if let Some(state) = self.buffers.get(&buffer_id) {
53                        let start_line = state.buffer.get_line_number(view_state.viewport.top_byte);
54                        let visible_lines =
55                            view_state.viewport.visible_line_count().saturating_sub(1);
56                        let end_line = start_line.saturating_add(visible_lines);
57                        semantic_ranges
58                            .entry(buffer_id)
59                            .and_modify(|(min_start, max_end)| {
60                                *min_start = (*min_start).min(start_line);
61                                *max_end = (*max_end).max(end_line);
62                            })
63                            .or_insert((start_line, end_line));
64                    }
65                }
66            }
67        }
68        for (buffer_id, (start_line, end_line)) in semantic_ranges {
69            self.maybe_request_semantic_tokens_range(buffer_id, start_line, end_line);
70            self.maybe_request_semantic_tokens_full_debounced(buffer_id);
71            self.maybe_request_folding_ranges_debounced(buffer_id);
72        }
73
74        {
75            let _span = tracing::info_span!("prepare_for_render").entered();
76            for (split_id, view_state) in &self.split_view_states {
77                if let Some(buffer_id) = self.split_manager.get_buffer_id((*split_id).into()) {
78                    if let Some(state) = self.buffers.get_mut(&buffer_id) {
79                        let top_byte = view_state.viewport.top_byte;
80                        let height = view_state.viewport.height;
81                        if let Err(e) = state.prepare_for_render(top_byte, height) {
82                            tracing::error!("Failed to prepare buffer for render: {}", e);
83                            // Continue with partial rendering
84                        }
85                    }
86                }
87            }
88        }
89
90        // Refresh search highlights only during incremental search (when prompt is active)
91        // After search is confirmed, overlays exist for ALL matches and shouldn't be overwritten
92        let is_search_prompt_active = self.prompt.as_ref().is_some_and(|p| {
93            matches!(
94                p.prompt_type,
95                PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch
96            )
97        });
98        if is_search_prompt_active {
99            if let Some(ref search_state) = self.search_state {
100                let query = search_state.query.clone();
101                self.update_search_highlights(&query);
102            }
103        }
104
105        // Determine if we need to show search options bar
106        let show_search_options = self.prompt.as_ref().is_some_and(|p| {
107            matches!(
108                p.prompt_type,
109                PromptType::Search
110                    | PromptType::ReplaceSearch
111                    | PromptType::Replace { .. }
112                    | PromptType::QueryReplaceSearch
113                    | PromptType::QueryReplace { .. }
114            )
115        });
116
117        // Hide status bar when suggestions popup or file browser popup is shown
118        let has_suggestions = self
119            .prompt
120            .as_ref()
121            .is_some_and(|p| !p.suggestions.is_empty());
122        let has_file_browser = self.prompt.as_ref().is_some_and(|p| {
123            matches!(
124                p.prompt_type,
125                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
126            )
127        }) && self.file_open_state.is_some();
128
129        // Build main vertical layout: [menu_bar, main_content, status_bar, search_options, prompt_line]
130        // Status bar is hidden when suggestions popup is shown
131        // Search options bar is shown when in search prompt
132        let constraints = vec![
133            Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }), // Menu bar
134            Constraint::Min(0),                                            // Main content area
135            Constraint::Length(
136                if !self.status_bar_visible || has_suggestions || has_file_browser {
137                    0
138                } else {
139                    1
140                },
141            ), // Status bar (hidden when toggled off or with popups)
142            Constraint::Length(if show_search_options { 1 } else { 0 }),   // Search options bar
143            Constraint::Length(if self.prompt_line_visible || self.prompt.is_some() {
144                1
145            } else {
146                0
147            }), // Prompt line (auto-hidden when no prompt active)
148        ];
149
150        let main_chunks = Layout::default()
151            .direction(Direction::Vertical)
152            .constraints(constraints)
153            .split(size);
154
155        let menu_bar_area = main_chunks[0];
156        let main_content_area = main_chunks[1];
157        let status_bar_idx = 2;
158        let search_options_idx = 3;
159        let prompt_line_idx = 4;
160
161        // Split main content area based on file explorer visibility
162        // Also keep the layout split if a sync is in progress (to avoid flicker)
163        let editor_content_area;
164        let file_explorer_should_show = self.file_explorer_visible
165            && (self.file_explorer.is_some() || self.file_explorer_sync_in_progress);
166
167        if file_explorer_should_show {
168            // Split horizontally: [file_explorer | editor]
169            tracing::trace!(
170                "render: file explorer layout active (present={}, sync_in_progress={})",
171                self.file_explorer.is_some(),
172                self.file_explorer_sync_in_progress
173            );
174            let explorer_cols = self.file_explorer_width.to_cols(main_content_area.width);
175            let horizontal_chunks = Layout::default()
176                .direction(Direction::Horizontal)
177                .constraints([
178                    Constraint::Length(explorer_cols), // File explorer
179                    Constraint::Min(0),                // Editor area (remainder)
180                ])
181                .split(main_content_area);
182
183            self.cached_layout.file_explorer_area = Some(horizontal_chunks[0]);
184            editor_content_area = horizontal_chunks[1];
185
186            // Get connection string before mutable borrow of file_explorer.
187            let remote_connection = self.connection_display_string();
188
189            // Render file explorer (only if we have it - during sync we just keep the area reserved)
190            if let Some(ref mut explorer) = self.file_explorer {
191                let is_focused = self.key_context == KeyContext::FileExplorer;
192
193                // Build set of files with unsaved changes
194                let mut files_with_unsaved_changes = std::collections::HashSet::new();
195                for (buffer_id, state) in &self.buffers {
196                    if state.buffer.is_modified() {
197                        if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
198                            if let Some(file_path) = metadata.file_path() {
199                                files_with_unsaved_changes.insert(file_path.clone());
200                            }
201                        }
202                    }
203                }
204
205                let close_button_hovered = matches!(
206                    &self.mouse_state.hover_target,
207                    Some(HoverTarget::FileExplorerCloseButton)
208                );
209                let keybindings = self.keybindings.read().unwrap();
210                let empty: Vec<std::path::PathBuf> = Vec::new();
211                let cut_paths = self
212                    .file_explorer_clipboard
213                    .as_ref()
214                    .filter(|cb| cb.is_cut)
215                    .map(|cb| cb.paths.as_slice())
216                    .unwrap_or(empty.as_slice());
217                FileExplorerRenderer::render(
218                    explorer,
219                    frame,
220                    horizontal_chunks[0],
221                    is_focused,
222                    &files_with_unsaved_changes,
223                    &self.file_explorer_decoration_cache,
224                    &keybindings,
225                    self.key_context.clone(),
226                    &self.theme,
227                    close_button_hovered,
228                    remote_connection.as_deref(),
229                    cut_paths,
230                );
231            }
232            // Note: if file_explorer is None but sync_in_progress is true,
233            // we just leave the area blank (or could render a placeholder)
234        } else {
235            // No file explorer: use entire main content area for editor
236            self.cached_layout.file_explorer_area = None;
237            editor_content_area = main_content_area;
238        }
239
240        // Note: Tabs are now rendered within each split by SplitRenderer
241
242        // Trigger lines_changed hooks for newly visible lines in all visible buffers
243        // This allows plugins to add overlays before rendering
244        // Only lines that haven't been seen before are sent (batched for efficiency)
245        // Use non-blocking hooks to avoid deadlock when actions are awaiting
246        if self.plugin_manager.is_active() {
247            let hooks_start = std::time::Instant::now();
248            // Get visible buffers and their areas
249            let visible_buffers = self.split_manager.get_visible_buffers(editor_content_area);
250
251            let mut total_new_lines = 0usize;
252            for (split_id, buffer_id, split_area) in visible_buffers {
253                // Get viewport from SplitViewState (the authoritative source)
254                let viewport_top_byte = self
255                    .split_view_states
256                    .get(&split_id)
257                    .map(|vs| vs.viewport.top_byte)
258                    .unwrap_or(0);
259
260                if let Some(state) = self.buffers.get_mut(&buffer_id) {
261                    // Fire render_start hook once per buffer
262                    self.plugin_manager.run_hook(
263                        "render_start",
264                        crate::services::plugins::hooks::HookArgs::RenderStart { buffer_id },
265                    );
266
267                    // Fire view_transform_request hook with base tokens
268                    // This allows plugins to transform the view (e.g., soft breaks for markdown)
269                    let visible_count = split_area.height as usize;
270                    let is_binary = state.buffer.is_binary();
271                    let line_ending = state.buffer.line_ending();
272                    let base_tokens =
273                        crate::view::ui::split_rendering::SplitRenderer::build_base_tokens_for_hook(
274                            &mut state.buffer,
275                            viewport_top_byte,
276                            self.config.editor.estimated_line_length,
277                            visible_count,
278                            is_binary,
279                            line_ending,
280                        );
281                    let viewport_start = viewport_top_byte;
282                    let viewport_end = base_tokens
283                        .last()
284                        .and_then(|t| t.source_offset)
285                        .unwrap_or(viewport_start);
286                    let cursor_positions: Vec<usize> = self
287                        .split_view_states
288                        .get(&split_id)
289                        .map(|vs| vs.cursors.iter().map(|(_, c)| c.position).collect())
290                        .unwrap_or_default();
291                    self.plugin_manager.run_hook(
292                        "view_transform_request",
293                        crate::services::plugins::hooks::HookArgs::ViewTransformRequest {
294                            buffer_id,
295                            split_id: split_id.into(),
296                            viewport_start,
297                            viewport_end,
298                            tokens: base_tokens,
299                            cursor_positions,
300                        },
301                    );
302
303                    // We just sent fresh base tokens to the plugin, so any
304                    // future SubmitViewTransform from this request will be valid.
305                    // Clear the stale flag so the response will be accepted.
306                    if let Some(vs) = self.split_view_states.get_mut(&split_id) {
307                        vs.view_transform_stale = false;
308                    }
309
310                    // Use the split area height as visible line count
311                    let visible_count = split_area.height as usize;
312                    let top_byte = viewport_top_byte;
313
314                    // Get or create the seen byte ranges set for this buffer
315                    let seen_byte_ranges = self.seen_byte_ranges.entry(buffer_id).or_default();
316
317                    // Collect only NEW lines (not seen before based on byte range)
318                    let mut new_lines: Vec<crate::services::plugins::hooks::LineInfo> = Vec::new();
319                    let mut line_number = state.buffer.get_line_number(top_byte);
320                    let mut iter = state
321                        .buffer
322                        .line_iterator(top_byte, self.config.editor.estimated_line_length);
323
324                    for _ in 0..visible_count {
325                        if let Some((line_start, line_content)) = iter.next_line() {
326                            let byte_end = line_start + line_content.len();
327                            let byte_range = (line_start, byte_end);
328
329                            // Only add if this byte range hasn't been seen before
330                            if !seen_byte_ranges.contains(&byte_range) {
331                                new_lines.push(crate::services::plugins::hooks::LineInfo {
332                                    line_number,
333                                    byte_start: line_start,
334                                    byte_end,
335                                    content: line_content,
336                                });
337                                seen_byte_ranges.insert(byte_range);
338                            }
339                            line_number += 1;
340                        } else {
341                            break;
342                        }
343                    }
344
345                    // Send batched hook if there are new lines
346                    if !new_lines.is_empty() {
347                        total_new_lines += new_lines.len();
348                        self.plugin_manager.run_hook(
349                            "lines_changed",
350                            crate::services::plugins::hooks::HookArgs::LinesChanged {
351                                buffer_id,
352                                lines: new_lines,
353                            },
354                        );
355                    }
356                }
357            }
358            let hooks_elapsed = hooks_start.elapsed();
359            tracing::trace!(
360                new_lines = total_new_lines,
361                elapsed_ms = hooks_elapsed.as_millis(),
362                elapsed_us = hooks_elapsed.as_micros(),
363                "lines_changed hooks total"
364            );
365
366            // Process any plugin commands (like AddOverlay) that resulted from the hooks.
367            //
368            // This is non-blocking: we collect whatever the plugin has sent so far.
369            // The plugin thread runs in parallel, and because we proactively call
370            // handle_refresh_lines after cursor_moved (in fire_cursor_hooks), the
371            // lines_changed hook fires early in the render cycle. By the time we
372            // reach this point, the plugin has typically already processed all hooks
373            // and sent back conceal/overlay commands. On rare occasions (high CPU
374            // load), the response arrives one frame late, which is imperceptible
375            // at 60fps. The plugin's own refreshLines() call from cursor_moved
376            // ensures a follow-up render cycle picks up any missed commands.
377            let commands = self.plugin_manager.process_commands();
378            if !commands.is_empty() {
379                let cmd_names: Vec<String> =
380                    commands.iter().map(|c| c.debug_variant_name()).collect();
381                tracing::trace!(count = commands.len(), cmds = ?cmd_names, "process_commands during render");
382            }
383            for command in commands {
384                if let Err(e) = self.handle_plugin_command(command) {
385                    tracing::error!("Error handling plugin command: {}", e);
386                }
387            }
388
389            // Flush any deferred grammar rebuilds as a single batch
390            self.flush_pending_grammars();
391        }
392
393        // Render editor content (same for both layouts)
394        let lsp_waiting = !self.pending_completion_requests.is_empty()
395            || self.pending_goto_definition_request.is_some();
396
397        // Hide the hardware cursor when menu is open, file explorer is focused, terminal mode,
398        // or settings UI is open
399        // (the file explorer will set its own cursor position when focused)
400        // (terminal mode renders its own cursor via the terminal emulator)
401        // (settings UI is a modal that doesn't need the editor cursor)
402        // This also causes visual cursor indicators in the editor to be dimmed
403        let settings_visible = self.settings_state.as_ref().is_some_and(|s| s.visible);
404        let hide_cursor = self.menu_state.active_menu.is_some()
405            || self.key_context == KeyContext::FileExplorer
406            || self.terminal_mode
407            || settings_visible
408            || self.keybinding_editor.is_some();
409
410        // Convert HoverTarget to tab hover info for rendering
411        let hovered_tab = match &self.mouse_state.hover_target {
412            Some(HoverTarget::TabName(target, split_id)) => Some((*target, *split_id, false)),
413            Some(HoverTarget::TabCloseButton(target, split_id)) => Some((*target, *split_id, true)),
414            _ => None,
415        };
416
417        // Get hovered close split button
418        let hovered_close_split = match &self.mouse_state.hover_target {
419            Some(HoverTarget::CloseSplitButton(split_id)) => Some(*split_id),
420            _ => None,
421        };
422
423        // Get hovered maximize split button
424        let hovered_maximize_split = match &self.mouse_state.hover_target {
425            Some(HoverTarget::MaximizeSplitButton(split_id)) => Some(*split_id),
426            _ => None,
427        };
428
429        let is_maximized = self.split_manager.is_maximized();
430
431        // The active split's buffer renderer records where the hardware
432        // cursor *wants* to appear here; we only commit it to the frame at
433        // the very end of this draw pass, after popups have been rendered,
434        // so a popup covering the cursor cell causes the cursor to be
435        // hidden (otherwise the hardware caret would bleed through the
436        // popup).
437        let mut pending_hardware_cursor: Option<(u16, u16)> = None;
438
439        let _content_span = tracing::info_span!("render_content").entered();
440        let (
441            split_areas,
442            tab_layouts,
443            close_split_areas,
444            maximize_split_areas,
445            view_line_mappings,
446            horizontal_scrollbar_areas,
447            grouped_separator_areas,
448        ) = SplitRenderer::render_content(
449            frame,
450            editor_content_area,
451            &self.split_manager,
452            &mut self.buffers,
453            &self.buffer_metadata,
454            &mut self.event_logs,
455            &mut self.composite_buffers,
456            &mut self.composite_view_states,
457            &self.theme,
458            self.ansi_background.as_ref(),
459            self.background_fade,
460            lsp_waiting,
461            self.config.editor.large_file_threshold_bytes,
462            self.config.editor.line_wrap,
463            self.config.editor.estimated_line_length,
464            self.config.editor.highlight_context_bytes,
465            Some(&mut self.split_view_states),
466            &self.grouped_subtrees,
467            hide_cursor,
468            hovered_tab,
469            hovered_close_split,
470            hovered_maximize_split,
471            is_maximized,
472            self.config.editor.relative_line_numbers,
473            self.tab_bar_visible,
474            self.config.editor.use_terminal_bg,
475            self.session_mode || !self.software_cursor_only,
476            self.software_cursor_only,
477            self.config.editor.show_vertical_scrollbar,
478            self.config.editor.show_horizontal_scrollbar,
479            self.config.editor.diagnostics_inline_text,
480            self.config.editor.show_tilde,
481            self.config.editor.highlight_current_column,
482            &mut self.cached_layout.cell_theme_map,
483            size.width,
484            &mut pending_hardware_cursor,
485        );
486
487        drop(_content_span);
488
489        // Detect viewport changes and fire hooks
490        // Compare against previous frame's viewport state (stored in self.previous_viewports)
491        // This correctly detects changes from scroll events that happen before render()
492        if self.plugin_manager.is_active() {
493            for (split_id, view_state) in &self.split_view_states {
494                let current = (
495                    view_state.viewport.top_byte,
496                    view_state.viewport.width,
497                    view_state.viewport.height,
498                );
499                // Compare against previous frame's state
500                // Skip new splits (None case) - only fire hooks for established splits
501                // This matches the original behavior where hooks only fire for splits
502                // that existed at the start of render
503                let (changed, previous) = match self.previous_viewports.get(split_id) {
504                    Some(previous) => (*previous != current, Some(*previous)),
505                    None => (false, None), // Skip new splits until they're established
506                };
507                tracing::trace!(
508                    "viewport_changed check: split={:?} current={:?} previous={:?} changed={}",
509                    split_id,
510                    current,
511                    previous,
512                    changed
513                );
514                if changed {
515                    if let Some(buffer_id) = self.split_manager.get_buffer_id((*split_id).into()) {
516                        // Compute top_line if line info is available
517                        let top_line = self.buffers.get(&buffer_id).and_then(|state| {
518                            if state.buffer.line_count().is_some() {
519                                Some(state.buffer.get_line_number(view_state.viewport.top_byte))
520                            } else {
521                                None
522                            }
523                        });
524                        tracing::debug!(
525                            "Firing viewport_changed hook: split={:?} buffer={:?} top_byte={} top_line={:?}",
526                            split_id,
527                            buffer_id,
528                            view_state.viewport.top_byte,
529                            top_line
530                        );
531                        self.plugin_manager.run_hook(
532                            "viewport_changed",
533                            crate::services::plugins::hooks::HookArgs::ViewportChanged {
534                                split_id: (*split_id).into(),
535                                buffer_id,
536                                top_byte: view_state.viewport.top_byte,
537                                top_line,
538                                width: view_state.viewport.width,
539                                height: view_state.viewport.height,
540                            },
541                        );
542                    }
543                }
544            }
545        }
546
547        // Update previous_viewports for next frame's comparison
548        self.previous_viewports.clear();
549        for (split_id, view_state) in &self.split_view_states {
550            self.previous_viewports.insert(
551                *split_id,
552                (
553                    view_state.viewport.top_byte,
554                    view_state.viewport.width,
555                    view_state.viewport.height,
556                ),
557            );
558        }
559
560        // Render terminal content on top of split content for terminal buffers
561        self.render_terminal_splits(frame, &split_areas);
562
563        self.cached_layout.split_areas = split_areas;
564        self.cached_layout.horizontal_scrollbar_areas = horizontal_scrollbar_areas;
565        self.cached_layout.tab_layouts = tab_layouts;
566        self.cached_layout.close_split_areas = close_split_areas;
567        self.cached_layout.maximize_split_areas = maximize_split_areas;
568        self.cached_layout.view_line_mappings = view_line_mappings;
569        let mut separator_areas = self
570            .split_manager
571            .get_separators_with_ids(editor_content_area);
572        // Grouped subtrees live in a side-map outside the main split tree, so
573        // their inner separators are not visited by `get_separators_with_ids`
574        // above. The renderer collected them (using the same content rect it
575        // drew them at) — merge so clicks on those rendered columns register.
576        separator_areas.extend(grouped_separator_areas);
577        self.cached_layout.separator_areas = separator_areas;
578        self.cached_layout.editor_content_area = Some(editor_content_area);
579
580        // Render hover highlights for separators and scrollbars
581        self.render_hover_highlights(frame);
582
583        // Initialize popup/suggestion layout state (rendered after status bar below)
584        self.cached_layout.suggestions_area = None;
585        self.file_browser_layout = None;
586
587        // Clone all immutable values before the mutable borrow
588        let display_name = self
589            .buffer_metadata
590            .get(&self.active_buffer())
591            .map(|m| m.display_name.clone())
592            .unwrap_or_else(|| "[No Name]".to_string());
593
594        // Reflect the active buffer in the terminal window/tab title. Only
595        // writes when the title actually changes so we don't flood stdout
596        // with OSC sequences every frame.
597        self.update_terminal_title(&display_name);
598
599        let status_message = self.status_message.clone();
600        let plugin_status_message = self.plugin_status_message.clone();
601        let prompt = self.prompt.clone();
602        // Compute a simple buffer-aware LSP indicator.
603        // Compose the LSP status-bar segment for the active buffer. This
604        // runs every render — the editor has no precomputed LSP-status
605        // string cached anywhere else, so there is a single source of
606        // truth for what the user sees.
607        //
608        // Priority order (first non-empty wins):
609        //
610        //   1. Active `$/progress` work for this language — e.g.
611        //      "LSP (cpp): indexing (42%)". Conveys the transient
612        //      startup/indexing phase.
613        //   2. A running server — "LSP". Short because detail belongs
614        //      in LSP-specific UI, not the compact status bar pill.
615        //   3. Configured `auto_start=true` servers that haven't started
616        //      (error / crashed / pending) — "LSP off".
617        //   4. Configured `enabled && !auto_start` servers that the user
618        //      has to opt into — "LSP: off (N)".
619        //   5. Nothing.
620        //
621        // Rules 3 and 4 address heuristic eval H-1: without them, a
622        // configured-but-dormant server is indistinguishable from "no
623        // LSP at all."
624        let current_language = self
625            .buffers
626            .get(&self.active_buffer())
627            .map(|s| s.language.clone())
628            .unwrap_or_default();
629        let buffer_lsp_disabled_reason = self
630            .buffer_metadata
631            .get(&self.active_buffer())
632            .filter(|m| !m.lsp_enabled)
633            .and_then(|m| m.lsp_disabled_reason.as_deref());
634        let (lsp_status, lsp_indicator_state) = compose_lsp_status(
635            &current_language,
636            buffer_lsp_disabled_reason,
637            &self.lsp_progress,
638            &self.lsp_server_statuses,
639            &self.config.lsp,
640            &self.user_dismissed_lsp_languages,
641        );
642        let theme = self.theme.clone();
643        let keybindings_cloned = self.keybindings.read().unwrap().clone(); // Clone the keybindings
644        let chord_state_cloned = self.chord_state.clone(); // Clone the chord state
645
646        // Get update availability info
647        let update_available = self.latest_version().map(|v| v.to_string());
648
649        // Render status bar (hidden when toggled off, or when suggestions/file browser popup is shown)
650        if self.status_bar_visible && !has_suggestions && !has_file_browser {
651            // Get warning level for colored indicator (respects config setting)
652            // LSP warning level is scoped to the current buffer's language
653            let (warning_level, general_warning_count) =
654                if self.config.warnings.show_status_indicator {
655                    let lsp_level = {
656                        use crate::services::async_bridge::LspServerStatus;
657                        let mut level = WarningLevel::None;
658                        for ((lang, _), status) in &self.lsp_server_statuses {
659                            if lang == &current_language {
660                                match status {
661                                    LspServerStatus::Error => {
662                                        level = WarningLevel::Error;
663                                        break;
664                                    }
665                                    LspServerStatus::Starting | LspServerStatus::Initializing => {
666                                        if level != WarningLevel::Error {
667                                            level = WarningLevel::Warning;
668                                        }
669                                    }
670                                    _ => {}
671                                }
672                            }
673                        }
674                        level
675                    };
676                    (lsp_level, self.get_general_warning_count())
677                } else {
678                    (WarningLevel::None, 0)
679                };
680
681            // Compute status bar hover state for styling
682            use crate::view::ui::status_bar::StatusBarHover;
683            let status_bar_hover = match &self.mouse_state.hover_target {
684                Some(HoverTarget::StatusBarLspIndicator) => StatusBarHover::LspIndicator,
685                Some(HoverTarget::StatusBarWarningBadge) => StatusBarHover::WarningBadge,
686                Some(HoverTarget::StatusBarLineEndingIndicator) => {
687                    StatusBarHover::LineEndingIndicator
688                }
689                Some(HoverTarget::StatusBarEncodingIndicator) => StatusBarHover::EncodingIndicator,
690                Some(HoverTarget::StatusBarLanguageIndicator) => StatusBarHover::LanguageIndicator,
691                Some(HoverTarget::StatusBarRemoteIndicator) => StatusBarHover::RemoteIndicator,
692                _ => StatusBarHover::None,
693            };
694
695            let remote_connection = self.connection_display_string();
696
697            // Get session name for display (only in session mode)
698            let session_name = self.session_name().map(|s| s.to_string());
699
700            let active_split = self.effective_active_split();
701            let active_buf = self.active_buffer();
702            let default_cursors = crate::model::cursor::Cursors::new();
703            let status_cursors = self
704                .split_view_states
705                .get(&active_split)
706                .map(|vs| &vs.cursors)
707                .unwrap_or(&default_cursors);
708            let is_read_only = self
709                .buffer_metadata
710                .get(&active_buf)
711                .map(|m| m.read_only)
712                .unwrap_or(false);
713            let mut status_ctx = crate::view::ui::status_bar::StatusBarContext {
714                state: self.buffers.get_mut(&active_buf).unwrap(),
715                cursors: status_cursors,
716                status_message: &status_message,
717                plugin_status_message: &plugin_status_message,
718                lsp_status: &lsp_status,
719                lsp_indicator_state,
720                theme: &theme,
721                display_name: &display_name,
722                keybindings: &keybindings_cloned,
723                chord_state: &chord_state_cloned,
724                update_available: update_available.as_deref(),
725                warning_level,
726                general_warning_count,
727                hover: status_bar_hover,
728                remote_connection: remote_connection.as_deref(),
729                session_name: session_name.as_deref(),
730                read_only: is_read_only,
731                remote_state_override: self.remote_indicator_override.as_ref(),
732            };
733            let status_bar_layout = StatusBarRenderer::render_status_bar(
734                frame,
735                main_chunks[status_bar_idx],
736                &mut status_ctx,
737                &self.config.editor.status_bar,
738            );
739
740            // Store status bar layout for click detection
741            let status_bar_area = main_chunks[status_bar_idx];
742            self.cached_layout.status_bar_area =
743                Some((status_bar_area.y, status_bar_area.x, status_bar_area.width));
744            self.cached_layout.status_bar_lsp_area = status_bar_layout.lsp_indicator;
745            self.cached_layout.status_bar_warning_area = status_bar_layout.warning_badge;
746            self.cached_layout.status_bar_line_ending_area =
747                status_bar_layout.line_ending_indicator;
748            self.cached_layout.status_bar_encoding_area = status_bar_layout.encoding_indicator;
749            self.cached_layout.status_bar_language_area = status_bar_layout.language_indicator;
750            self.cached_layout.status_bar_message_area = status_bar_layout.message_area;
751            self.cached_layout.status_bar_remote_area = status_bar_layout.remote_indicator;
752        }
753
754        // Render search options bar when in search prompt
755        if show_search_options {
756            // Show "Confirm" option only in replace modes
757            let confirm_each = self.prompt.as_ref().and_then(|p| {
758                if matches!(
759                    p.prompt_type,
760                    PromptType::ReplaceSearch
761                        | PromptType::Replace { .. }
762                        | PromptType::QueryReplaceSearch
763                        | PromptType::QueryReplace { .. }
764                ) {
765                    Some(self.search_confirm_each)
766                } else {
767                    None
768                }
769            });
770
771            // Determine hover state for search options
772            use crate::view::ui::status_bar::SearchOptionsHover;
773            let search_options_hover = match &self.mouse_state.hover_target {
774                Some(HoverTarget::SearchOptionCaseSensitive) => SearchOptionsHover::CaseSensitive,
775                Some(HoverTarget::SearchOptionWholeWord) => SearchOptionsHover::WholeWord,
776                Some(HoverTarget::SearchOptionRegex) => SearchOptionsHover::Regex,
777                Some(HoverTarget::SearchOptionConfirmEach) => SearchOptionsHover::ConfirmEach,
778                _ => SearchOptionsHover::None,
779            };
780
781            let search_options_layout = StatusBarRenderer::render_search_options(
782                frame,
783                main_chunks[search_options_idx],
784                self.search_case_sensitive,
785                self.search_whole_word,
786                self.search_use_regex,
787                confirm_each,
788                &theme,
789                &keybindings_cloned,
790                search_options_hover,
791            );
792            self.cached_layout.search_options_layout = Some(search_options_layout);
793        } else {
794            self.cached_layout.search_options_layout = None;
795        }
796
797        // Render prompt line if active
798        if let Some(prompt) = &prompt {
799            // Use specialized renderer for file/folder open prompt to show colorized path
800            if matches!(
801                prompt.prompt_type,
802                crate::view::prompt::PromptType::OpenFile
803                    | crate::view::prompt::PromptType::SwitchProject
804            ) {
805                if let Some(file_open_state) = &self.file_open_state {
806                    StatusBarRenderer::render_file_open_prompt(
807                        frame,
808                        main_chunks[prompt_line_idx],
809                        prompt,
810                        file_open_state,
811                        &theme,
812                    );
813                } else {
814                    StatusBarRenderer::render_prompt(
815                        frame,
816                        main_chunks[prompt_line_idx],
817                        prompt,
818                        &theme,
819                    );
820                }
821            } else {
822                StatusBarRenderer::render_prompt(
823                    frame,
824                    main_chunks[prompt_line_idx],
825                    prompt,
826                    &theme,
827                );
828            }
829        }
830
831        // Render file browser popup or suggestions popup AFTER status bar + prompt,
832        // so they overlay on top of both (fixes bottom border being overwritten by status bar)
833        self.render_prompt_popups(frame, main_chunks[prompt_line_idx], size.width);
834
835        // Render popups from the active buffer state
836        // Clone theme to avoid borrow checker issues with active_state_mut()
837        let theme_clone = self.theme.clone();
838        let hover_target = self.mouse_state.hover_target.clone();
839
840        // Clear popup areas and recalculate
841        self.cached_layout.popup_areas.clear();
842
843        // Collect popup information without holding a mutable borrow
844        let popup_info: Vec<_> = {
845            // Get viewport from active split's SplitViewState
846            let active_split = self.split_manager.active_split();
847            let viewport = self
848                .split_view_states
849                .get(&active_split)
850                .map(|vs| vs.viewport.clone());
851
852            // Get the content_rect for the active split from the cached layout.
853            // This is the absolute screen rect (already accounts for file explorer,
854            // tab bar, scrollbars, etc.). The gutter is rendered inside this rect,
855            // so we add gutter_width to get the text content origin.
856            let content_rect = self
857                .cached_layout
858                .split_areas
859                .iter()
860                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
861                .map(|(_, _, rect, _, _, _)| *rect);
862
863            let primary_cursor = self
864                .split_view_states
865                .get(&active_split)
866                .map(|vs| *vs.cursors.primary());
867            let state = self.active_state_mut();
868            if state.popups.is_visible() {
869                // Get the primary cursor position for popup positioning
870                let primary_cursor =
871                    primary_cursor.unwrap_or_else(|| crate::model::cursor::Cursor::new(0));
872
873                // Compute gutter width so we know where text content starts
874                let gutter_width = viewport
875                    .as_ref()
876                    .map(|vp| vp.gutter_width(&state.buffer) as u16)
877                    .unwrap_or(0);
878
879                let cursor_screen_pos = viewport
880                    .as_ref()
881                    .map(|vp| vp.cursor_screen_position(&mut state.buffer, &primary_cursor))
882                    .unwrap_or((0, 0));
883
884                // For completion popups, compute the word-start screen position so
885                // the popup aligns with the beginning of the word being completed,
886                // not the current cursor position.
887                let word_start_screen_pos = {
888                    use crate::primitives::word_navigation::find_completion_word_start;
889                    let word_start =
890                        find_completion_word_start(&state.buffer, primary_cursor.position);
891                    let word_start_cursor = crate::model::cursor::Cursor::new(word_start);
892                    viewport
893                        .as_ref()
894                        .map(|vp| vp.cursor_screen_position(&mut state.buffer, &word_start_cursor))
895                        .unwrap_or((0, 0))
896                };
897
898                // Use content_rect as the single source of truth for the text
899                // content area origin. content_rect.x is the split's left edge
900                // (already past the file explorer), content_rect.y is below the
901                // tab bar. Adding gutter_width gives us the text content start.
902                let (base_x, base_y) = content_rect
903                    .map(|r| (r.x + gutter_width, r.y))
904                    .unwrap_or((gutter_width, 1));
905
906                let cursor_screen_pos =
907                    (cursor_screen_pos.0 + base_x, cursor_screen_pos.1 + base_y);
908                let word_start_screen_pos = (
909                    word_start_screen_pos.0 + base_x,
910                    word_start_screen_pos.1 + base_y,
911                );
912
913                // Collect popup data
914                state
915                    .popups
916                    .all()
917                    .iter()
918                    .enumerate()
919                    .map(|(popup_idx, popup)| {
920                        // Use word-start x for completion popups, cursor x for others
921                        let popup_pos = if popup.kind == crate::view::popup::PopupKind::Completion {
922                            (word_start_screen_pos.0, cursor_screen_pos.1)
923                        } else {
924                            cursor_screen_pos
925                        };
926                        let popup_area = popup.calculate_area(size, Some(popup_pos));
927
928                        // Track popup area for mouse hit testing
929                        // Account for description height when calculating the list item area
930                        let desc_height = popup.description_height();
931                        let inner_area = if popup.bordered {
932                            ratatui::layout::Rect {
933                                x: popup_area.x + 1,
934                                y: popup_area.y + 1 + desc_height,
935                                width: popup_area.width.saturating_sub(2),
936                                height: popup_area.height.saturating_sub(2 + desc_height),
937                            }
938                        } else {
939                            ratatui::layout::Rect {
940                                x: popup_area.x,
941                                y: popup_area.y + desc_height,
942                                width: popup_area.width,
943                                height: popup_area.height.saturating_sub(desc_height),
944                            }
945                        };
946
947                        let num_items = match &popup.content {
948                            crate::view::popup::PopupContent::List { items, .. } => items.len(),
949                            _ => 0,
950                        };
951
952                        // Calculate total content lines and scrollbar rect
953                        let total_lines = popup.item_count();
954                        let visible_lines = inner_area.height as usize;
955                        let scrollbar_rect = if total_lines > visible_lines && inner_area.width > 2
956                        {
957                            Some(ratatui::layout::Rect {
958                                x: inner_area.x + inner_area.width - 1,
959                                y: inner_area.y,
960                                width: 1,
961                                height: inner_area.height,
962                            })
963                        } else {
964                            None
965                        };
966
967                        (
968                            popup_idx,
969                            popup_area,
970                            inner_area,
971                            popup.scroll_offset,
972                            num_items,
973                            scrollbar_rect,
974                            total_lines,
975                        )
976                    })
977                    .collect()
978            } else {
979                Vec::new()
980            }
981        };
982
983        // Store popup areas for mouse hit testing
984        self.cached_layout.popup_areas = popup_info.clone();
985
986        // Now render popups
987        let state = self.active_state_mut();
988        if state.popups.is_visible() {
989            for (popup_idx, popup) in state.popups.all().iter().enumerate() {
990                if let Some((_, popup_area, _, _, _, _, _)) = popup_info.get(popup_idx) {
991                    popup.render_with_hover(
992                        frame,
993                        *popup_area,
994                        &theme_clone,
995                        hover_target.as_ref(),
996                    );
997                }
998            }
999        }
1000
1001        // Render editor-level popups (e.g. plugin action popups) on top of any
1002        // buffer content so they stay visible across buffer switches and over
1003        // virtual buffers (Dashboard, diagnostics) that own the whole split.
1004        // These don't need cursor-relative positioning — they all use absolute
1005        // positions like BottomRight or Centered.
1006        //
1007        // Queue semantics: concurrent action popups stack in `global_popups`,
1008        // but only the top one renders & receives input. Deeper popups
1009        // surface as the top is resolved — the alternative (drawing all at
1010        // the same BottomRight slot) makes them illegible.
1011        self.cached_layout.global_popup_areas.clear();
1012        if let Some(popup) = self.global_popups.top() {
1013            let top_idx = self.global_popups.all().len() - 1;
1014            let popup_area = popup.calculate_area(size, None);
1015            let desc_height = popup.description_height();
1016            let inner_area = if popup.bordered {
1017                ratatui::layout::Rect {
1018                    x: popup_area.x + 1,
1019                    y: popup_area.y + 1 + desc_height,
1020                    width: popup_area.width.saturating_sub(2),
1021                    height: popup_area.height.saturating_sub(2 + desc_height),
1022                }
1023            } else {
1024                ratatui::layout::Rect {
1025                    x: popup_area.x,
1026                    y: popup_area.y + desc_height,
1027                    width: popup_area.width,
1028                    height: popup_area.height.saturating_sub(desc_height),
1029                }
1030            };
1031            let num_items = match &popup.content {
1032                crate::view::popup::PopupContent::List { items, .. } => items.len(),
1033                _ => 0,
1034            };
1035            self.cached_layout.global_popup_areas.push((
1036                top_idx,
1037                popup_area,
1038                inner_area,
1039                popup.scroll_offset,
1040                num_items,
1041            ));
1042            popup.render_with_hover(frame, popup_area, &theme_clone, hover_target.as_ref());
1043        }
1044
1045        // Render menu bar last so dropdown appears on top of all other content
1046        // Update menu context with current editor state
1047        self.update_menu_context();
1048
1049        // Render settings modal (before menu bar so menus can overlay)
1050        // Check visibility first to avoid borrow conflict with dimming
1051        let settings_visible = self
1052            .settings_state
1053            .as_ref()
1054            .map(|s| s.visible)
1055            .unwrap_or(false);
1056        if settings_visible {
1057            // Dim the editor content behind the settings modal
1058            crate::view::dimming::apply_dimming(frame, size);
1059        }
1060        if let Some(ref mut settings_state) = self.settings_state {
1061            if settings_state.visible {
1062                settings_state.update_focus_states();
1063                let settings_layout = crate::view::settings::render_settings(
1064                    frame,
1065                    size,
1066                    settings_state,
1067                    &self.theme,
1068                );
1069                self.cached_layout.settings_layout = Some(settings_layout);
1070            }
1071        }
1072
1073        // Render calibration wizard if active
1074        if let Some(ref wizard) = self.calibration_wizard {
1075            // Dim the editor content behind the wizard modal
1076            crate::view::dimming::apply_dimming(frame, size);
1077            crate::view::calibration_wizard::render_calibration_wizard(
1078                frame,
1079                size,
1080                wizard,
1081                &self.theme,
1082            );
1083        }
1084
1085        // Render keybinding editor if active
1086        if let Some(ref mut kb_editor) = self.keybinding_editor {
1087            crate::view::dimming::apply_dimming(frame, size);
1088            crate::view::keybinding_editor::render_keybinding_editor(
1089                frame,
1090                size,
1091                kb_editor,
1092                &self.theme,
1093            );
1094        }
1095
1096        // Render event debug dialog if active
1097        if let Some(ref debug) = self.event_debug {
1098            // Dim the editor content behind the dialog modal
1099            crate::view::dimming::apply_dimming(frame, size);
1100            crate::view::event_debug::render_event_debug(frame, size, debug, &self.theme);
1101        }
1102
1103        if self.menu_bar_visible {
1104            let keybindings = self.keybindings.read().unwrap();
1105            self.cached_layout.menu_layout = Some(crate::view::ui::MenuRenderer::render(
1106                frame,
1107                menu_bar_area,
1108                &self.menus,
1109                &self.menu_state,
1110                &keybindings,
1111                &self.theme,
1112                self.mouse_state.hover_target.as_ref(),
1113                self.config.editor.menu_bar_mnemonics,
1114            ));
1115        } else {
1116            self.cached_layout.menu_layout = None;
1117        }
1118
1119        // Render tab context menu if open
1120        if let Some(ref menu) = self.tab_context_menu {
1121            self.render_tab_context_menu(frame, menu);
1122        }
1123
1124        if let Some(ref menu) = self.file_explorer_context_menu {
1125            self.render_file_explorer_context_menu(frame, menu);
1126        }
1127
1128        // Record non-editor region theme keys for the theme inspector
1129        self.record_non_editor_theme_regions();
1130
1131        // Render theme info popup (Ctrl+Right-Click)
1132        self.render_theme_info_popup(frame);
1133
1134        // Render tab drag drop zone overlay if dragging a tab
1135        if let Some(ref drag_state) = self.mouse_state.dragging_tab {
1136            if drag_state.is_dragging() {
1137                self.render_tab_drop_zone(frame, drag_state);
1138            }
1139        }
1140
1141        // Render software mouse cursor when GPM is active
1142        // GPM can't draw its cursor on the alternate screen buffer used by TUI apps,
1143        // so we draw our own cursor at the tracked mouse position.
1144        // This must happen LAST in the render flow so we can read the already-rendered
1145        // cell content and invert it.
1146        if self.gpm_active {
1147            if let Some((col, row)) = self.mouse_cursor_position {
1148                use ratatui::style::Modifier;
1149
1150                // Only render if within screen bounds
1151                if col < size.width && row < size.height {
1152                    // Get the cell at this position and add REVERSED modifier to invert colors
1153                    let buf = frame.buffer_mut();
1154                    if let Some(cell) = buf.cell_mut((col, row)) {
1155                        cell.set_style(cell.style().add_modifier(Modifier::REVERSED));
1156                    }
1157                }
1158            }
1159        }
1160
1161        // When keyboard capture mode is active, dim all UI elements outside the terminal
1162        // to visually indicate that focus is exclusively on the terminal
1163        if self.keyboard_capture && self.terminal_mode {
1164            // Find the active split's content area
1165            let active_split = self.split_manager.active_split();
1166            let active_split_area = self
1167                .cached_layout
1168                .split_areas
1169                .iter()
1170                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
1171                .map(|(_, _, content_rect, _, _, _)| *content_rect);
1172
1173            if let Some(terminal_area) = active_split_area {
1174                self.apply_keyboard_capture_dimming(frame, terminal_area);
1175            }
1176        }
1177
1178        // Commit the active-split hardware cursor (deferred since
1179        // `render_content`) unless a popup has been drawn over that cell.
1180        // Ratatui draws the hardware caret on top of every cell, so a
1181        // popup cannot hide the cursor by painting cells — the only way
1182        // to hide it is to leave `Frame::cursor_position` as `None`, which
1183        // triggers `Terminal::hide_cursor` at the end of the draw.
1184        //
1185        // When a prompt is active the prompt renderer already placed the
1186        // caret on the prompt line via `frame.set_cursor_position`; don't
1187        // override it with the (now-irrelevant) buffer cursor.
1188        if let Some((cx, cy)) = pending_hardware_cursor {
1189            if self.prompt.is_none() && !self.cursor_obscured_by_overlay(cx, cy) {
1190                frame.set_cursor_position((cx, cy));
1191            }
1192        }
1193
1194        // Convert all colors for terminal capability (256/16 color fallback)
1195        crate::view::color_support::convert_buffer_colors(
1196            frame.buffer_mut(),
1197            self.color_capability,
1198        );
1199    }
1200
1201    /// Returns true if `(x, y)` falls inside any popup-style overlay that
1202    /// was rendered this frame. Used to decide whether the hardware cursor
1203    /// should be shown or hidden so it does not bleed through a popup.
1204    fn cursor_obscured_by_overlay(&self, x: u16, y: u16) -> bool {
1205        let inside = |rect: ratatui::layout::Rect| -> bool {
1206            x >= rect.x
1207                && x < rect.x.saturating_add(rect.width)
1208                && y >= rect.y
1209                && y < rect.y.saturating_add(rect.height)
1210        };
1211
1212        if self
1213            .cached_layout
1214            .popup_areas
1215            .iter()
1216            .any(|entry| inside(entry.1))
1217        {
1218            return true;
1219        }
1220        if self
1221            .cached_layout
1222            .global_popup_areas
1223            .iter()
1224            .any(|entry| inside(entry.1))
1225        {
1226            return true;
1227        }
1228        if let Some((rect, _, _, _)) = self.cached_layout.suggestions_area {
1229            if inside(rect) {
1230                return true;
1231            }
1232        }
1233        if let Some(ref fb) = self.file_browser_layout {
1234            if inside(fb.popup_area) {
1235                return true;
1236            }
1237        }
1238        false
1239    }
1240
1241    /// Render the Quick Open hints line showing available mode prefixes
1242    fn render_quick_open_hints(
1243        frame: &mut Frame,
1244        area: ratatui::layout::Rect,
1245        theme: &crate::view::theme::Theme,
1246    ) {
1247        use ratatui::style::{Modifier, Style};
1248        use ratatui::text::{Line, Span};
1249        use ratatui::widgets::Paragraph;
1250        use rust_i18n::t;
1251
1252        let hints_style = Style::default()
1253            .fg(theme.line_number_fg)
1254            .bg(theme.suggestion_selected_bg)
1255            .add_modifier(Modifier::DIM);
1256        let hints_text = t!("quick_open.mode_hints");
1257        // Left-align with small margin
1258        let left_margin = 2;
1259        let hints_width = crate::primitives::display_width::str_width(&hints_text);
1260        let mut spans = Vec::new();
1261        spans.push(Span::styled(" ".repeat(left_margin), hints_style));
1262        spans.push(Span::styled(hints_text.to_string(), hints_style));
1263        let remaining = (area.width as usize).saturating_sub(left_margin + hints_width);
1264        spans.push(Span::styled(" ".repeat(remaining), hints_style));
1265
1266        let paragraph = Paragraph::new(Line::from(spans));
1267        frame.render_widget(paragraph, area);
1268    }
1269
1270    /// Apply dimming effect to UI elements outside the focused terminal area
1271    /// This visually indicates that keyboard capture mode is active
1272    fn apply_keyboard_capture_dimming(
1273        &self,
1274        frame: &mut Frame,
1275        terminal_area: ratatui::layout::Rect,
1276    ) {
1277        let size = frame.area();
1278        crate::view::dimming::apply_dimming_excluding(frame, size, Some(terminal_area));
1279    }
1280
1281    /// Render file browser or suggestions popup as overlay above the prompt line.
1282    /// Called after status bar + prompt so the popup draws on top of both.
1283    fn render_prompt_popups(
1284        &mut self,
1285        frame: &mut Frame,
1286        prompt_area: ratatui::layout::Rect,
1287        width: u16,
1288    ) {
1289        let Some(prompt) = &self.prompt else { return };
1290
1291        if matches!(
1292            prompt.prompt_type,
1293            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
1294        ) {
1295            let Some(file_open_state) = &self.file_open_state else {
1296                return;
1297            };
1298            let max_height = prompt_area.y.saturating_sub(1).min(20);
1299            let popup_area = ratatui::layout::Rect {
1300                x: 0,
1301                y: prompt_area.y.saturating_sub(max_height),
1302                width,
1303                height: max_height,
1304            };
1305            let keybindings = self.keybindings.read().unwrap();
1306            self.file_browser_layout = crate::view::ui::FileBrowserRenderer::render(
1307                frame,
1308                popup_area,
1309                file_open_state,
1310                &self.theme,
1311                &self.mouse_state.hover_target,
1312                Some(&*keybindings),
1313            );
1314            return;
1315        }
1316
1317        if prompt.suggestions.is_empty() {
1318            return;
1319        }
1320
1321        let suggestion_count = prompt.suggestions.len().min(10);
1322        let is_quick_open = prompt.prompt_type == crate::view::prompt::PromptType::QuickOpen;
1323        let hints_height: u16 = if is_quick_open { 1 } else { 0 };
1324        let height = suggestion_count as u16 + 2 + hints_height;
1325
1326        let suggestions_area = ratatui::layout::Rect {
1327            x: 0,
1328            y: prompt_area.y.saturating_sub(height),
1329            width,
1330            height: height - hints_height,
1331        };
1332
1333        frame.render_widget(ratatui::widgets::Clear, suggestions_area);
1334
1335        self.cached_layout.suggestions_area = SuggestionsRenderer::render_with_hover(
1336            frame,
1337            suggestions_area,
1338            prompt,
1339            &self.theme,
1340            self.mouse_state.hover_target.as_ref(),
1341        );
1342
1343        if is_quick_open {
1344            let hints_area = ratatui::layout::Rect {
1345                x: 0,
1346                y: prompt_area.y.saturating_sub(hints_height),
1347                width,
1348                height: hints_height,
1349            };
1350            frame.render_widget(ratatui::widgets::Clear, hints_area);
1351            Self::render_quick_open_hints(frame, hints_area, &self.theme);
1352        }
1353    }
1354
1355    /// Render hover highlights for interactive elements (separators, scrollbars)
1356    pub(super) fn render_hover_highlights(&self, frame: &mut Frame) {
1357        use ratatui::style::Style;
1358        use ratatui::text::Span;
1359        use ratatui::widgets::Paragraph;
1360
1361        match &self.mouse_state.hover_target {
1362            Some(HoverTarget::SplitSeparator(split_id, direction)) => {
1363                // Highlight the separator with hover color
1364                for (sid, dir, x, y, length) in &self.cached_layout.separator_areas {
1365                    if sid == split_id && dir == direction {
1366                        let hover_style = Style::default().fg(self.theme.split_separator_hover_fg);
1367                        match dir {
1368                            SplitDirection::Horizontal => {
1369                                let line_text = "─".repeat(*length as usize);
1370                                let paragraph =
1371                                    Paragraph::new(Span::styled(line_text, hover_style));
1372                                frame.render_widget(
1373                                    paragraph,
1374                                    ratatui::layout::Rect::new(*x, *y, *length, 1),
1375                                );
1376                            }
1377                            SplitDirection::Vertical => {
1378                                for offset in 0..*length {
1379                                    let paragraph = Paragraph::new(Span::styled("│", hover_style));
1380                                    frame.render_widget(
1381                                        paragraph,
1382                                        ratatui::layout::Rect::new(*x, y + offset, 1, 1),
1383                                    );
1384                                }
1385                            }
1386                        }
1387                    }
1388                }
1389            }
1390            Some(HoverTarget::ScrollbarThumb(split_id)) => {
1391                // Highlight scrollbar thumb
1392                for (sid, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
1393                    &self.cached_layout.split_areas
1394                {
1395                    if sid == split_id {
1396                        let hover_style = Style::default().bg(self.theme.scrollbar_thumb_hover_fg);
1397                        for row_offset in *thumb_start..*thumb_end {
1398                            let paragraph = Paragraph::new(Span::styled(" ", hover_style));
1399                            frame.render_widget(
1400                                paragraph,
1401                                ratatui::layout::Rect::new(
1402                                    scrollbar_rect.x,
1403                                    scrollbar_rect.y + row_offset as u16,
1404                                    1,
1405                                    1,
1406                                ),
1407                            );
1408                        }
1409                    }
1410                }
1411            }
1412            Some(HoverTarget::ScrollbarTrack(split_id, hovered_row)) => {
1413                // Highlight only the hovered cell on the scrollbar track
1414                for (sid, _buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
1415                    &self.cached_layout.split_areas
1416                {
1417                    if sid == split_id {
1418                        let track_hover_style =
1419                            Style::default().bg(self.theme.scrollbar_track_hover_fg);
1420                        let paragraph = Paragraph::new(Span::styled(" ", track_hover_style));
1421                        frame.render_widget(
1422                            paragraph,
1423                            ratatui::layout::Rect::new(
1424                                scrollbar_rect.x,
1425                                scrollbar_rect.y + hovered_row,
1426                                1,
1427                                1,
1428                            ),
1429                        );
1430                    }
1431                }
1432            }
1433            Some(HoverTarget::FileExplorerBorder) => {
1434                // Highlight the file explorer border for resize
1435                if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1436                    let hover_style = Style::default().fg(self.theme.split_separator_hover_fg);
1437                    let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
1438                    for row_offset in 0..explorer_area.height {
1439                        let paragraph = Paragraph::new(Span::styled("│", hover_style));
1440                        frame.render_widget(
1441                            paragraph,
1442                            ratatui::layout::Rect::new(
1443                                border_x,
1444                                explorer_area.y + row_offset,
1445                                1,
1446                                1,
1447                            ),
1448                        );
1449                    }
1450                }
1451            }
1452            // Menu hover is handled by MenuRenderer
1453            _ => {}
1454        }
1455    }
1456
1457    /// Render the tab context menu
1458    fn render_tab_context_menu(&self, frame: &mut Frame, menu: &TabContextMenu) {
1459        use ratatui::style::Style;
1460        use ratatui::text::{Line, Span};
1461        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1462
1463        let items = super::types::TabContextMenuItem::all();
1464        let menu_width = 22u16; // "Close to the Right" + padding
1465        let menu_height = items.len() as u16 + 2; // items + borders
1466
1467        // Adjust position to stay within screen bounds
1468        let screen_width = frame.area().width;
1469        let screen_height = frame.area().height;
1470
1471        let menu_x = if menu.position.0 + menu_width > screen_width {
1472            screen_width.saturating_sub(menu_width)
1473        } else {
1474            menu.position.0
1475        };
1476
1477        let menu_y = if menu.position.1 + menu_height > screen_height {
1478            screen_height.saturating_sub(menu_height)
1479        } else {
1480            menu.position.1
1481        };
1482
1483        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
1484
1485        // Clear the area first
1486        frame.render_widget(Clear, area);
1487
1488        // Build the menu lines
1489        let mut lines = Vec::new();
1490        for (idx, item) in items.iter().enumerate() {
1491            let is_highlighted = idx == menu.highlighted;
1492
1493            let style = if is_highlighted {
1494                Style::default()
1495                    .fg(self.theme.menu_highlight_fg)
1496                    .bg(self.theme.menu_highlight_bg)
1497            } else {
1498                Style::default()
1499                    .fg(self.theme.menu_dropdown_fg)
1500                    .bg(self.theme.menu_dropdown_bg)
1501            };
1502
1503            // Pad the label to fill the menu width
1504            let label = item.label();
1505            let content_width = (menu_width as usize).saturating_sub(2); // -2 for borders
1506            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
1507
1508            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
1509        }
1510
1511        let block = Block::default()
1512            .borders(Borders::ALL)
1513            .border_style(Style::default().fg(self.theme.menu_border_fg))
1514            .style(Style::default().bg(self.theme.menu_dropdown_bg));
1515
1516        let paragraph = Paragraph::new(lines).block(block);
1517        frame.render_widget(paragraph, area);
1518    }
1519
1520    /// Render the file explorer context menu
1521    fn render_file_explorer_context_menu(
1522        &self,
1523        frame: &mut Frame,
1524        menu: &super::types::FileExplorerContextMenu,
1525    ) {
1526        use ratatui::style::Style;
1527        use ratatui::text::{Line, Span};
1528        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1529
1530        let items = menu.items();
1531        let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
1532        let menu_height = menu.height();
1533        let (menu_x, menu_y) = menu.clamped_position(frame.area().width, frame.area().height);
1534
1535        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
1536
1537        frame.render_widget(Clear, area);
1538
1539        let mut lines = Vec::new();
1540        for (idx, item) in items.iter().enumerate() {
1541            let is_highlighted = idx == menu.highlighted;
1542
1543            let style = if is_highlighted {
1544                Style::default()
1545                    .fg(self.theme.menu_highlight_fg)
1546                    .bg(self.theme.menu_highlight_bg)
1547            } else {
1548                Style::default()
1549                    .fg(self.theme.menu_dropdown_fg)
1550                    .bg(self.theme.menu_dropdown_bg)
1551            };
1552
1553            let label = item.label();
1554            let content_width = (menu_width as usize).saturating_sub(2);
1555            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
1556
1557            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
1558        }
1559
1560        let block = Block::default()
1561            .borders(Borders::ALL)
1562            .border_style(Style::default().fg(self.theme.menu_border_fg))
1563            .style(Style::default().bg(self.theme.menu_dropdown_bg));
1564
1565        let paragraph = Paragraph::new(lines).block(block);
1566        frame.render_widget(paragraph, area);
1567    }
1568
1569    /// Render the tab drag drop zone overlay
1570    fn render_tab_drop_zone(&self, frame: &mut Frame, drag_state: &super::types::TabDragState) {
1571        use ratatui::style::Modifier;
1572
1573        let Some(ref drop_zone) = drag_state.drop_zone else {
1574            return;
1575        };
1576
1577        let split_id = drop_zone.split_id();
1578
1579        // Find the content area for the target split
1580        let split_area = self
1581            .cached_layout
1582            .split_areas
1583            .iter()
1584            .find(|(sid, _, _, _, _, _)| *sid == split_id)
1585            .map(|(_, _, content_rect, _, _, _)| *content_rect);
1586
1587        let Some(content_rect) = split_area else {
1588            return;
1589        };
1590
1591        // Determine the highlight area based on drop zone type
1592        use super::types::TabDropZone;
1593
1594        let highlight_area = match drop_zone {
1595            TabDropZone::TabBar(_, _) | TabDropZone::SplitCenter(_) => {
1596                // For tab bar and center drops, highlight the entire split area
1597                // This indicates the tab will be added to this split's tab bar
1598                content_rect
1599            }
1600            TabDropZone::SplitLeft(_) => {
1601                // Left 50% of the split (matches the actual split size created)
1602                let width = (content_rect.width / 2).max(3);
1603                ratatui::layout::Rect::new(
1604                    content_rect.x,
1605                    content_rect.y,
1606                    width,
1607                    content_rect.height,
1608                )
1609            }
1610            TabDropZone::SplitRight(_) => {
1611                // Right 50% of the split (matches the actual split size created)
1612                let width = (content_rect.width / 2).max(3);
1613                let x = content_rect.x + content_rect.width - width;
1614                ratatui::layout::Rect::new(x, content_rect.y, width, content_rect.height)
1615            }
1616            TabDropZone::SplitTop(_) => {
1617                // Top 50% of the split (matches the actual split size created)
1618                let height = (content_rect.height / 2).max(2);
1619                ratatui::layout::Rect::new(
1620                    content_rect.x,
1621                    content_rect.y,
1622                    content_rect.width,
1623                    height,
1624                )
1625            }
1626            TabDropZone::SplitBottom(_) => {
1627                // Bottom 50% of the split (matches the actual split size created)
1628                let height = (content_rect.height / 2).max(2);
1629                let y = content_rect.y + content_rect.height - height;
1630                ratatui::layout::Rect::new(content_rect.x, y, content_rect.width, height)
1631            }
1632        };
1633
1634        // Draw the overlay with the drop zone color
1635        // We apply a semi-transparent effect by modifying existing cells
1636        let buf = frame.buffer_mut();
1637        let drop_zone_bg = self.theme.tab_drop_zone_bg;
1638        let drop_zone_border = self.theme.tab_drop_zone_border;
1639
1640        // Fill the highlight area with a semi-transparent overlay
1641        for y in highlight_area.y..highlight_area.y + highlight_area.height {
1642            for x in highlight_area.x..highlight_area.x + highlight_area.width {
1643                if let Some(cell) = buf.cell_mut((x, y)) {
1644                    // Blend the drop zone color with the existing background
1645                    // For a simple effect, we just set the background
1646                    cell.set_bg(drop_zone_bg);
1647
1648                    // Draw border on edges
1649                    let is_border = x == highlight_area.x
1650                        || x == highlight_area.x + highlight_area.width - 1
1651                        || y == highlight_area.y
1652                        || y == highlight_area.y + highlight_area.height - 1;
1653
1654                    if is_border {
1655                        cell.set_fg(drop_zone_border);
1656                        cell.set_style(cell.style().add_modifier(Modifier::BOLD));
1657                    }
1658                }
1659            }
1660        }
1661
1662        // Draw a border indicator based on the zone type
1663        match drop_zone {
1664            TabDropZone::SplitLeft(_) => {
1665                // Draw vertical indicator on left edge
1666                for y in highlight_area.y..highlight_area.y + highlight_area.height {
1667                    if let Some(cell) = buf.cell_mut((highlight_area.x, y)) {
1668                        cell.set_symbol("▌");
1669                        cell.set_fg(drop_zone_border);
1670                    }
1671                }
1672            }
1673            TabDropZone::SplitRight(_) => {
1674                // Draw vertical indicator on right edge
1675                let x = highlight_area.x + highlight_area.width - 1;
1676                for y in highlight_area.y..highlight_area.y + highlight_area.height {
1677                    if let Some(cell) = buf.cell_mut((x, y)) {
1678                        cell.set_symbol("▐");
1679                        cell.set_fg(drop_zone_border);
1680                    }
1681                }
1682            }
1683            TabDropZone::SplitTop(_) => {
1684                // Draw horizontal indicator on top edge
1685                for x in highlight_area.x..highlight_area.x + highlight_area.width {
1686                    if let Some(cell) = buf.cell_mut((x, highlight_area.y)) {
1687                        cell.set_symbol("▀");
1688                        cell.set_fg(drop_zone_border);
1689                    }
1690                }
1691            }
1692            TabDropZone::SplitBottom(_) => {
1693                // Draw horizontal indicator on bottom edge
1694                let y = highlight_area.y + highlight_area.height - 1;
1695                for x in highlight_area.x..highlight_area.x + highlight_area.width {
1696                    if let Some(cell) = buf.cell_mut((x, y)) {
1697                        cell.set_symbol("▄");
1698                        cell.set_fg(drop_zone_border);
1699                    }
1700                }
1701            }
1702            TabDropZone::SplitCenter(_) | TabDropZone::TabBar(_, _) => {
1703                // For center and tab bar, the filled background is sufficient
1704            }
1705        }
1706    }
1707
1708    /// Recompute the view_line_mappings layout without drawing.
1709    /// Used during macro replay so that visual-line movements (MoveLineEnd,
1710    /// MoveUp, MoveDown on wrapped lines) see correct, up-to-date layout
1711    /// information between each replayed action.
1712    pub fn recompute_layout(&mut self, width: u16, height: u16) {
1713        let size = ratatui::layout::Rect::new(0, 0, width, height);
1714
1715        // Replicate the pre-render sync steps from render()
1716        let active_split = self.split_manager.active_split();
1717        self.pre_sync_ensure_visible(active_split);
1718        self.sync_scroll_groups();
1719
1720        // Replicate the layout computation that produces editor_content_area.
1721        // Same constraints as render(): [menu_bar, main_content, status_bar, search_options, prompt_line]
1722        let constraints = vec![
1723            Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }),
1724            Constraint::Min(0),
1725            Constraint::Length(if self.status_bar_visible { 1 } else { 0 }), // status bar
1726            Constraint::Length(0), // search options (doesn't matter for layout)
1727            Constraint::Length(if self.prompt_line_visible { 1 } else { 0 }), // prompt line
1728        ];
1729        let main_chunks = Layout::default()
1730            .direction(Direction::Vertical)
1731            .constraints(constraints)
1732            .split(size);
1733        let main_content_area = main_chunks[1];
1734
1735        // Compute editor_content_area (with file explorer split if visible)
1736        let file_explorer_should_show = self.file_explorer_visible
1737            && (self.file_explorer.is_some() || self.file_explorer_sync_in_progress);
1738        let editor_content_area = if file_explorer_should_show {
1739            let explorer_cols = self.file_explorer_width.to_cols(main_content_area.width);
1740            let horizontal_chunks = Layout::default()
1741                .direction(Direction::Horizontal)
1742                .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
1743                .split(main_content_area);
1744            horizontal_chunks[1]
1745        } else {
1746            main_content_area
1747        };
1748
1749        // Compute layout for all visible splits and update cached view_line_mappings
1750        let view_line_mappings = SplitRenderer::compute_content_layout(
1751            editor_content_area,
1752            &self.split_manager,
1753            &mut self.buffers,
1754            &mut self.split_view_states,
1755            &self.theme,
1756            false, // lsp_waiting — not relevant for layout
1757            self.config.editor.estimated_line_length,
1758            self.config.editor.highlight_context_bytes,
1759            self.config.editor.relative_line_numbers,
1760            self.config.editor.use_terminal_bg,
1761            self.session_mode || !self.software_cursor_only,
1762            self.software_cursor_only,
1763            self.tab_bar_visible,
1764            self.config.editor.show_vertical_scrollbar,
1765            self.config.editor.show_horizontal_scrollbar,
1766            self.config.editor.diagnostics_inline_text,
1767            self.config.editor.show_tilde,
1768        );
1769
1770        self.cached_layout.view_line_mappings = view_line_mappings;
1771    }
1772
1773    /// Clear the search history
1774    /// Used primarily for testing to ensure test isolation
1775    pub fn clear_search_history(&mut self) {
1776        if let Some(history) = self.prompt_histories.get_mut("search") {
1777            history.clear();
1778        }
1779    }
1780
1781    /// Emit an OSC 2 escape sequence to set the host terminal's window/tab
1782    /// title based on the active buffer's display name. Deduplicated against
1783    /// the last title we wrote so we don't spam stdout every frame.
1784    ///
1785    /// Gated by `editor.set_window_title` (default on). Terminals that
1786    /// don't implement OSC 2 silently drop the sequence.
1787    fn update_terminal_title(&mut self, display_name: &str) {
1788        if !self.config.editor.set_window_title {
1789            return;
1790        }
1791        let new_title = format!("{} \u{2014} Fresh", display_name);
1792        if self.last_window_title.as_deref() == Some(new_title.as_str()) {
1793            return;
1794        }
1795        crate::services::terminal_title::write_terminal_title(&new_title);
1796        self.last_window_title = Some(new_title);
1797    }
1798
1799    /// Save all prompt histories to disk
1800    /// Called on shutdown to persist history across sessions
1801    pub fn save_histories(&self) {
1802        // Ensure data directory exists
1803        if let Err(e) = self
1804            .authority
1805            .filesystem
1806            .create_dir_all(&self.dir_context.data_dir)
1807        {
1808            tracing::warn!("Failed to create data directory: {}", e);
1809            return;
1810        }
1811
1812        // Save all prompt histories
1813        for (key, history) in &self.prompt_histories {
1814            let path = self.dir_context.prompt_history_path(key);
1815            if let Err(e) = history.save_to_file(&path) {
1816                tracing::warn!("Failed to save {} history: {}", key, e);
1817            } else {
1818                tracing::debug!("Saved {} history to {:?}", key, path);
1819            }
1820        }
1821    }
1822}