Skip to main content

fresh/app/
render.rs

1use super::lsp_status::compose_lsp_status;
2use super::*;
3use crate::config::FileExplorerSide;
4
5impl Editor {
6    /// Render the editor to the terminal
7    pub fn render(&mut self, frame: &mut Frame) {
8        let _span = tracing::info_span!("render").entered();
9        let size = frame.area();
10
11        // Let active animations snapshot the previous frame's buffer
12        // from the runner's own cache. We can't read the live
13        // `frame.buffer_mut()` — ratatui resets it before each draw —
14        // so the runner keeps a post-apply clone from the last frame.
15        self.animations.capture_before_all();
16
17        // Save frame dimensions for recompute_layout (used by macro replay)
18        self.cached_layout.last_frame_width = size.width;
19        self.cached_layout.last_frame_height = size.height;
20
21        // Reset per-cell theme key map for this frame
22        self.cached_layout.reset_cell_theme_map();
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        // (Held in mutable bindings because the in-render
107        // `process_commands` block below can dispatch commands —
108        // e.g. `StartPromptAsync`, `SetPromptSuggestions` — that
109        // mutate `self.prompt`. When that happens we recompute these
110        // flags and re-split `main_chunks` so the bottom-row
111        // rendering uses an up-to-date layout. See the
112        // "Recompute layout if mid-render commands changed state"
113        // block below.)
114        let mut show_search_options = self.prompt.as_ref().is_some_and(|p| {
115            matches!(
116                p.prompt_type,
117                PromptType::Search
118                    | PromptType::ReplaceSearch
119                    | PromptType::Replace { .. }
120                    | PromptType::QueryReplaceSearch
121                    | PromptType::QueryReplace { .. }
122            )
123        });
124
125        // Hide status bar when suggestions popup or file browser
126        // popup is shown — those popups float just above the prompt
127        // line, and a visible status bar wedged between them looks
128        // wrong. Floating-overlay prompts (Live Grep, issue #1796)
129        // are exempt because their suggestions live inside the
130        // centred frame, not above the bottom row.
131        let mut prompt_is_overlay = self.prompt.as_ref().is_some_and(|p| p.overlay);
132        let mut has_suggestions = self
133            .prompt
134            .as_ref()
135            .is_some_and(|p| !p.suggestions.is_empty())
136            && !prompt_is_overlay;
137        let mut has_file_browser = self.prompt.as_ref().is_some_and(|p| {
138            matches!(
139                p.prompt_type,
140                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
141            )
142        }) && self.file_open_state.is_some();
143
144        // Build main vertical layout: [menu_bar, main_content, status_bar, search_options, prompt_line]
145        // Status bar is hidden when suggestions popup is shown
146        // Search options bar is shown when in search prompt
147        let mut main_chunks = Layout::default()
148            .direction(Direction::Vertical)
149            .constraints(vec![
150                Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }), // Menu bar
151                Constraint::Min(0),                                            // Main content area
152                Constraint::Length(
153                    if !self.status_bar_visible || has_suggestions || has_file_browser {
154                        0
155                    } else {
156                        1
157                    },
158                ), // Status bar (hidden when toggled off or with popups)
159                Constraint::Length(if show_search_options { 1 } else { 0 }),   // Search options bar
160                Constraint::Length(
161                    // Prompt line is auto-hidden when no prompt active.
162                    // Overlay prompts (Live Grep, issue #1796) host the
163                    // input row inside the centred frame, so the
164                    // bottom row stays available for editor content
165                    // rather than being reserved as dead space.
166                    if (self.prompt_line_visible || self.prompt.is_some()) && !prompt_is_overlay {
167                        1
168                    } else {
169                        0
170                    },
171                ), // Prompt line
172            ])
173            .split(size);
174
175        let menu_bar_area = main_chunks[0];
176        let main_content_area = main_chunks[1];
177        let status_bar_idx = 2;
178        let search_options_idx = 3;
179        let prompt_line_idx = 4;
180
181        // Split main content area based on file explorer visibility
182        // Also keep the layout split if a sync is in progress (to avoid flicker)
183        let editor_content_area;
184        let file_explorer_should_show = self.file_explorer_visible
185            && (self.file_explorer.is_some() || self.file_explorer_sync_in_progress);
186
187        if file_explorer_should_show {
188            // Split horizontally based on side placement
189            tracing::trace!(
190                "render: file explorer layout active (present={}, sync_in_progress={}, side={:?})",
191                self.file_explorer.is_some(),
192                self.file_explorer_sync_in_progress,
193                self.file_explorer_side
194            );
195            let explorer_cols = self.file_explorer_width.to_cols(main_content_area.width);
196
197            let (explorer_area, editor_area) = match self.file_explorer_side {
198                FileExplorerSide::Left => {
199                    let chunks = Layout::default()
200                        .direction(Direction::Horizontal)
201                        .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
202                        .split(main_content_area);
203                    (chunks[0], chunks[1])
204                }
205                FileExplorerSide::Right => {
206                    let chunks = Layout::default()
207                        .direction(Direction::Horizontal)
208                        .constraints([Constraint::Min(0), Constraint::Length(explorer_cols)])
209                        .split(main_content_area);
210                    (chunks[1], chunks[0])
211                }
212            };
213
214            self.cached_layout.file_explorer_area = Some(explorer_area);
215            editor_content_area = editor_area;
216
217            // Get connection string before mutable borrow of file_explorer.
218            let remote_connection = self.connection_display_string();
219
220            // Render file explorer (only if we have it - during sync we just keep the area reserved)
221            if let Some(ref mut explorer) = self.file_explorer {
222                let is_focused = self.key_context == KeyContext::FileExplorer;
223
224                // Build set of files with unsaved changes
225                let mut files_with_unsaved_changes = std::collections::HashSet::new();
226                for (buffer_id, state) in &self.buffers {
227                    if state.buffer.is_modified() {
228                        if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
229                            if let Some(file_path) = metadata.file_path() {
230                                files_with_unsaved_changes.insert(file_path.clone());
231                            }
232                        }
233                    }
234                }
235
236                let close_button_hovered = matches!(
237                    &self.mouse_state.hover_target,
238                    Some(HoverTarget::FileExplorerCloseButton)
239                );
240                let keybindings = self.keybindings.read().unwrap();
241                let empty: Vec<std::path::PathBuf> = Vec::new();
242                let cut_paths = self
243                    .file_explorer_clipboard
244                    .as_ref()
245                    .filter(|cb| cb.is_cut)
246                    .map(|cb| cb.paths.as_slice())
247                    .unwrap_or(empty.as_slice());
248                FileExplorerRenderer::render(
249                    explorer,
250                    frame,
251                    explorer_area,
252                    is_focused,
253                    &files_with_unsaved_changes,
254                    &self.file_explorer_decoration_cache,
255                    &keybindings,
256                    self.key_context.clone(),
257                    &self.theme,
258                    close_button_hovered,
259                    remote_connection.as_deref(),
260                    cut_paths,
261                );
262            }
263            // Note: if file_explorer is None but sync_in_progress is true,
264            // we just leave the area blank (or could render a placeholder)
265        } else {
266            // No file explorer: use entire main content area for editor
267            self.cached_layout.file_explorer_area = None;
268            editor_content_area = main_content_area;
269        }
270
271        // Note: Tabs are now rendered within each split by SplitRenderer
272
273        // Trigger lines_changed hooks for newly visible lines in all visible buffers
274        // This allows plugins to add overlays before rendering
275        // Only lines that haven't been seen before are sent (batched for efficiency)
276        // Use non-blocking hooks to avoid deadlock when actions are awaiting
277        if self.plugin_manager.is_active() {
278            let hooks_start = std::time::Instant::now();
279            // Get visible buffers and their areas
280            let visible_buffers = self.split_manager.get_visible_buffers(editor_content_area);
281
282            let mut total_new_lines = 0usize;
283            for (split_id, buffer_id, split_area) in visible_buffers {
284                // Get viewport from SplitViewState (the authoritative source)
285                let viewport_top_byte = self
286                    .split_view_states
287                    .get(&split_id)
288                    .map(|vs| vs.viewport.top_byte)
289                    .unwrap_or(0);
290
291                if let Some(state) = self.buffers.get_mut(&buffer_id) {
292                    // Fire render_start hook once per buffer
293                    self.plugin_manager.run_hook(
294                        "render_start",
295                        crate::services::plugins::hooks::HookArgs::RenderStart { buffer_id },
296                    );
297
298                    // Fire view_transform_request hook with base tokens
299                    // This allows plugins to transform the view (e.g., soft breaks for markdown)
300                    let visible_count = split_area.height as usize;
301                    let is_binary = state.buffer.is_binary();
302                    let line_ending = state.buffer.line_ending();
303                    let base_tokens =
304                        crate::view::ui::split_rendering::SplitRenderer::build_base_tokens_for_hook(
305                            &mut state.buffer,
306                            viewport_top_byte,
307                            self.config.editor.estimated_line_length,
308                            visible_count,
309                            is_binary,
310                            line_ending,
311                        );
312                    let viewport_start = viewport_top_byte;
313                    let viewport_end = base_tokens
314                        .last()
315                        .and_then(|t| t.source_offset)
316                        .unwrap_or(viewport_start);
317                    let cursor_positions: Vec<usize> = self
318                        .split_view_states
319                        .get(&split_id)
320                        .map(|vs| vs.cursors.iter().map(|(_, c)| c.position).collect())
321                        .unwrap_or_default();
322                    self.plugin_manager.run_hook(
323                        "view_transform_request",
324                        crate::services::plugins::hooks::HookArgs::ViewTransformRequest {
325                            buffer_id,
326                            split_id: split_id.into(),
327                            viewport_start,
328                            viewport_end,
329                            tokens: base_tokens,
330                            cursor_positions,
331                        },
332                    );
333
334                    // We just sent fresh base tokens to the plugin, so any
335                    // future SubmitViewTransform from this request will be valid.
336                    // Clear the stale flag so the response will be accepted.
337                    if let Some(vs) = self.split_view_states.get_mut(&split_id) {
338                        vs.view_transform_stale = false;
339                    }
340
341                    // Use the split area height as visible line count
342                    let visible_count = split_area.height as usize;
343                    let top_byte = viewport_top_byte;
344
345                    // Get or create the seen byte ranges set for this buffer
346                    let seen_byte_ranges = self.seen_byte_ranges.entry(buffer_id).or_default();
347
348                    // Collect only NEW lines (not seen before based on byte range)
349                    let mut new_lines: Vec<crate::services::plugins::hooks::LineInfo> = Vec::new();
350                    let mut line_number = state.buffer.get_line_number(top_byte);
351                    let mut iter = state
352                        .buffer
353                        .line_iterator(top_byte, self.config.editor.estimated_line_length);
354
355                    for _ in 0..visible_count {
356                        if let Some((line_start, line_content)) = iter.next_line() {
357                            let byte_end = line_start + line_content.len();
358                            let byte_range = (line_start, byte_end);
359
360                            // Only add if this byte range hasn't been seen before
361                            if !seen_byte_ranges.contains(&byte_range) {
362                                new_lines.push(crate::services::plugins::hooks::LineInfo {
363                                    line_number,
364                                    byte_start: line_start,
365                                    byte_end,
366                                    content: line_content,
367                                });
368                                seen_byte_ranges.insert(byte_range);
369                            }
370                            line_number += 1;
371                        } else {
372                            break;
373                        }
374                    }
375
376                    // Send batched hook if there are new lines
377                    if !new_lines.is_empty() {
378                        total_new_lines += new_lines.len();
379                        self.plugin_manager.run_hook(
380                            "lines_changed",
381                            crate::services::plugins::hooks::HookArgs::LinesChanged {
382                                buffer_id,
383                                lines: new_lines,
384                            },
385                        );
386                    }
387                }
388            }
389            let hooks_elapsed = hooks_start.elapsed();
390            tracing::trace!(
391                new_lines = total_new_lines,
392                elapsed_ms = hooks_elapsed.as_millis(),
393                elapsed_us = hooks_elapsed.as_micros(),
394                "lines_changed hooks total"
395            );
396
397            // Process any plugin commands (like AddOverlay) that resulted from the hooks.
398            //
399            // This is non-blocking: we collect whatever the plugin has sent so far.
400            // The plugin thread runs in parallel, and because we proactively call
401            // handle_refresh_lines after cursor_moved (in fire_cursor_hooks), the
402            // lines_changed hook fires early in the render cycle. By the time we
403            // reach this point, the plugin has typically already processed all hooks
404            // and sent back conceal/overlay commands. On rare occasions (high CPU
405            // load), the response arrives one frame late, which is imperceptible
406            // at 60fps. The plugin's own refreshLines() call from cursor_moved
407            // ensures a follow-up render cycle picks up any missed commands.
408            let commands = self.plugin_manager.process_commands();
409            let dispatched_any = !commands.is_empty();
410            if dispatched_any {
411                let cmd_names: Vec<String> =
412                    commands.iter().map(|c| c.debug_variant_name()).collect();
413                tracing::trace!(count = commands.len(), cmds = ?cmd_names, "process_commands during render");
414            }
415            for command in commands {
416                if let Err(e) = self.handle_plugin_command(command) {
417                    tracing::error!("Error handling plugin command: {}", e);
418                }
419            }
420
421            // Flush any deferred grammar rebuilds as a single batch
422            self.flush_pending_grammars();
423
424            // Recompute the bottom-row layout if the in-render command
425            // dispatch above mutated state that affects it. Without
426            // this, a `StartPromptAsync` (or similar) processed
427            // mid-render leaves `main_chunks` reflecting the prior
428            // `self.prompt = None` shape — the prompt slot ends up at
429            // (y = size.height, h = 0) and the status bar paints the
430            // bottom row in place of the prompt input. Conservative:
431            // we recompute on *any* dispatched commands rather than
432            // enumerating layout-affecting variants — Layout::split is
433            // cheap, and this avoids a maintenance-burden whitelist
434            // that would silently regress as new `PluginCommand`
435            // variants are added.
436            //
437            // Bounded — single drain + single recompute. We do not
438            // call `process_commands` again, so commands queued by
439            // hooks fired inside the dispatch above wait for the next
440            // render or `editor_tick` (the existing one-frame-late
441            // behaviour the comment above already accepts).
442            //
443            // `main_content_area` (and the file-explorer / split
444            // rendering derived from it earlier in this render) is
445            // intentionally NOT re-derived: those areas were already
446            // painted, and the bottom-row recompute may overwrite a
447            // single row of main content where the new status bar /
448            // prompt now sits. That brief overlap self-corrects on
449            // the next frame, where the layout is built consistently
450            // from the start.
451            if dispatched_any {
452                show_search_options = self.prompt.as_ref().is_some_and(|p| {
453                    matches!(
454                        p.prompt_type,
455                        PromptType::Search
456                            | PromptType::ReplaceSearch
457                            | PromptType::Replace { .. }
458                            | PromptType::QueryReplaceSearch
459                            | PromptType::QueryReplace { .. }
460                    )
461                });
462                prompt_is_overlay = self.prompt.as_ref().is_some_and(|p| p.overlay);
463                has_suggestions = self
464                    .prompt
465                    .as_ref()
466                    .is_some_and(|p| !p.suggestions.is_empty())
467                    && !prompt_is_overlay;
468                has_file_browser = self.prompt.as_ref().is_some_and(|p| {
469                    matches!(
470                        p.prompt_type,
471                        PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
472                    )
473                }) && self.file_open_state.is_some();
474                main_chunks = Layout::default()
475                    .direction(Direction::Vertical)
476                    .constraints(vec![
477                        Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }),
478                        Constraint::Min(0),
479                        Constraint::Length(
480                            if !self.status_bar_visible || has_suggestions || has_file_browser {
481                                0
482                            } else {
483                                1
484                            },
485                        ),
486                        Constraint::Length(if show_search_options { 1 } else { 0 }),
487                        Constraint::Length(
488                            if (self.prompt_line_visible || self.prompt.is_some())
489                                && !prompt_is_overlay
490                            {
491                                1
492                            } else {
493                                0
494                            },
495                        ),
496                    ])
497                    .split(size);
498            }
499        }
500
501        // Render editor content (same for both layouts)
502        let lsp_waiting = !self.pending_completion_requests.is_empty()
503            || self.pending_goto_definition_request.is_some();
504
505        // Hide the hardware cursor when menu is open, file explorer is focused, terminal mode,
506        // or settings UI is open
507        // (the file explorer will set its own cursor position when focused)
508        // (terminal mode renders its own cursor via the terminal emulator)
509        // (settings UI is a modal that doesn't need the editor cursor)
510        // This also causes visual cursor indicators in the editor to be dimmed
511        let settings_visible = self.settings_state.as_ref().is_some_and(|s| s.visible);
512        let hide_cursor = self.menu_state.active_menu.is_some()
513            || self.key_context == KeyContext::FileExplorer
514            || self.terminal_mode
515            || settings_visible
516            || self.keybinding_editor.is_some();
517
518        // Convert HoverTarget to tab hover info for rendering
519        let hovered_tab = match &self.mouse_state.hover_target {
520            Some(HoverTarget::TabName(target, split_id)) => Some((*target, *split_id, false)),
521            Some(HoverTarget::TabCloseButton(target, split_id)) => Some((*target, *split_id, true)),
522            _ => None,
523        };
524
525        // Get hovered close split button
526        let hovered_close_split = match &self.mouse_state.hover_target {
527            Some(HoverTarget::CloseSplitButton(split_id)) => Some(*split_id),
528            _ => None,
529        };
530
531        // Get hovered maximize split button
532        let hovered_maximize_split = match &self.mouse_state.hover_target {
533            Some(HoverTarget::MaximizeSplitButton(split_id)) => Some(*split_id),
534            _ => None,
535        };
536
537        let is_maximized = self.split_manager.is_maximized();
538
539        // The active split's buffer renderer records where the hardware
540        // cursor *wants* to appear here; we only commit it to the frame at
541        // the very end of this draw pass, after popups have been rendered,
542        // so a popup covering the cursor cell causes the cursor to be
543        // hidden (otherwise the hardware caret would bleed through the
544        // popup).
545        let mut pending_hardware_cursor: Option<(u16, u16)> = None;
546
547        let _content_span = tracing::info_span!("render_content").entered();
548        let (
549            split_areas,
550            tab_layouts,
551            close_split_areas,
552            maximize_split_areas,
553            view_line_mappings,
554            horizontal_scrollbar_areas,
555            grouped_separator_areas,
556        ) = SplitRenderer::render_content(
557            frame,
558            editor_content_area,
559            &self.split_manager,
560            &mut self.buffers,
561            &self.buffer_metadata,
562            &mut self.event_logs,
563            &mut self.composite_buffers,
564            &mut self.composite_view_states,
565            &self.theme,
566            self.ansi_background.as_ref(),
567            self.background_fade,
568            lsp_waiting,
569            self.config.editor.large_file_threshold_bytes,
570            self.config.editor.line_wrap,
571            self.config.editor.estimated_line_length,
572            self.config.editor.highlight_context_bytes,
573            Some(&mut self.split_view_states),
574            &self.grouped_subtrees,
575            hide_cursor,
576            hovered_tab,
577            hovered_close_split,
578            hovered_maximize_split,
579            is_maximized,
580            self.config.editor.relative_line_numbers,
581            self.tab_bar_visible,
582            self.config.editor.use_terminal_bg,
583            self.session_mode || !self.software_cursor_only,
584            self.software_cursor_only,
585            self.config.editor.show_vertical_scrollbar,
586            self.config.editor.show_horizontal_scrollbar,
587            self.config.editor.diagnostics_inline_text,
588            self.config.editor.show_tilde,
589            self.config.editor.highlight_current_column,
590            &mut self.cached_layout.cell_theme_map,
591            size.width,
592            &mut pending_hardware_cursor,
593        );
594
595        drop(_content_span);
596
597        // Cursor-jump animation: compare the cursor's screen position to
598        // the prior frame and animate either when the cursor crossed split
599        // panes or moved more than two rows within the same pane. The
600        // trail crosses pane separators when the jump is across splits —
601        // that's the intended "follow the focus" cue.
602        self.maybe_start_cursor_jump_animation(pending_hardware_cursor, active_split);
603
604        // Detect viewport changes and fire hooks
605        // Compare against previous frame's viewport state (stored in self.previous_viewports)
606        // This correctly detects changes from scroll events that happen before render()
607        if self.plugin_manager.is_active() {
608            for (split_id, view_state) in &self.split_view_states {
609                let current = (
610                    view_state.viewport.top_byte,
611                    view_state.viewport.width,
612                    view_state.viewport.height,
613                );
614                // Compare against previous frame's state
615                // Skip new splits (None case) - only fire hooks for established splits
616                // This matches the original behavior where hooks only fire for splits
617                // that existed at the start of render
618                let (changed, previous) = match self.previous_viewports.get(split_id) {
619                    Some(previous) => (*previous != current, Some(*previous)),
620                    None => (false, None), // Skip new splits until they're established
621                };
622                tracing::trace!(
623                    "viewport_changed check: split={:?} current={:?} previous={:?} changed={}",
624                    split_id,
625                    current,
626                    previous,
627                    changed
628                );
629                if changed {
630                    if let Some(buffer_id) = self.split_manager.get_buffer_id((*split_id).into()) {
631                        // Compute top_line if line info is available
632                        let top_line = self.buffers.get(&buffer_id).and_then(|state| {
633                            if state.buffer.line_count().is_some() {
634                                Some(state.buffer.get_line_number(view_state.viewport.top_byte))
635                            } else {
636                                None
637                            }
638                        });
639                        tracing::debug!(
640                            "Firing viewport_changed hook: split={:?} buffer={:?} top_byte={} top_line={:?}",
641                            split_id,
642                            buffer_id,
643                            view_state.viewport.top_byte,
644                            top_line
645                        );
646                        self.plugin_manager.run_hook(
647                            "viewport_changed",
648                            crate::services::plugins::hooks::HookArgs::ViewportChanged {
649                                split_id: (*split_id).into(),
650                                buffer_id,
651                                top_byte: view_state.viewport.top_byte,
652                                top_line,
653                                width: view_state.viewport.width,
654                                height: view_state.viewport.height,
655                            },
656                        );
657                    }
658                }
659            }
660        }
661
662        // Update previous_viewports for next frame's comparison
663        self.previous_viewports.clear();
664        for (split_id, view_state) in &self.split_view_states {
665            self.previous_viewports.insert(
666                *split_id,
667                (
668                    view_state.viewport.top_byte,
669                    view_state.viewport.width,
670                    view_state.viewport.height,
671                ),
672            );
673        }
674
675        // Render terminal content on top of split content for terminal buffers
676        self.render_terminal_splits(frame, &split_areas);
677
678        self.cached_layout.split_areas = split_areas;
679        self.cached_layout.horizontal_scrollbar_areas = horizontal_scrollbar_areas;
680        self.cached_layout.tab_layouts = tab_layouts;
681        self.cached_layout.close_split_areas = close_split_areas;
682        self.cached_layout.maximize_split_areas = maximize_split_areas;
683        self.cached_layout.view_line_mappings = view_line_mappings;
684
685        // Promote any deferred virtual-buffer animations whose Rect is now
686        // known. Done here (after split_areas is recomputed, before
687        // apply_all runs at the end of render) so the first frame of the
688        // effect lands on the same paint that made the buffer visible.
689        self.drain_pending_vb_animations();
690        let mut separator_areas = self
691            .split_manager
692            .get_separators_with_ids(editor_content_area);
693        // Grouped subtrees live in a side-map outside the main split tree, so
694        // their inner separators are not visited by `get_separators_with_ids`
695        // above. The renderer collected them (using the same content rect it
696        // drew them at) — merge so clicks on those rendered columns register.
697        separator_areas.extend(grouped_separator_areas);
698        self.cached_layout.separator_areas = separator_areas;
699        self.cached_layout.editor_content_area = Some(editor_content_area);
700
701        // Render hover highlights for separators and scrollbars
702        self.render_hover_highlights(frame);
703
704        // Initialize popup/suggestion layout state (rendered after status bar below)
705        self.cached_layout.suggestions_area = None;
706        self.cached_layout.suggestions_outer_area = None;
707        self.file_browser_layout = None;
708
709        // Clone all immutable values before the mutable borrow
710        let display_name = self
711            .buffer_metadata
712            .get(&self.active_buffer())
713            .map(|m| m.display_name.clone())
714            .unwrap_or_else(|| "[No Name]".to_string());
715
716        // Reflect the active buffer in the terminal window/tab title. Only
717        // writes when the title actually changes so we don't flood stdout
718        // with OSC sequences every frame.
719        self.update_terminal_title(&display_name);
720
721        let status_message = self.status_message.clone();
722        let plugin_status_message = self.plugin_status_message.clone();
723        let prompt = self.prompt.clone();
724        // Compute a simple buffer-aware LSP indicator.
725        // Compose the LSP status-bar segment for the active buffer. This
726        // runs every render — the editor has no precomputed LSP-status
727        // string cached anywhere else, so there is a single source of
728        // truth for what the user sees.
729        //
730        // Priority order (first non-empty wins):
731        //
732        //   1. Active `$/progress` work for this language — e.g.
733        //      "LSP (cpp): indexing (42%)". Conveys the transient
734        //      startup/indexing phase.
735        //   2. A running server — "LSP". Short because detail belongs
736        //      in LSP-specific UI, not the compact status bar pill.
737        //   3. Configured `auto_start=true` servers that haven't started
738        //      (error / crashed / pending) — "LSP off".
739        //   4. Configured `enabled && !auto_start` servers that the user
740        //      has to opt into — "LSP: off (N)".
741        //   5. Nothing.
742        //
743        // Rules 3 and 4 address heuristic eval H-1: without them, a
744        // configured-but-dormant server is indistinguishable from "no
745        // LSP at all."
746        let current_language = self
747            .buffers
748            .get(&self.active_buffer())
749            .map(|s| s.language.clone())
750            .unwrap_or_default();
751        let buffer_lsp_disabled_reason = self
752            .buffer_metadata
753            .get(&self.active_buffer())
754            .filter(|m| !m.lsp_enabled)
755            .and_then(|m| m.lsp_disabled_reason.as_deref());
756        let (lsp_status, lsp_indicator_state) = compose_lsp_status(
757            &current_language,
758            buffer_lsp_disabled_reason,
759            &self.lsp_progress,
760            &self.lsp_server_statuses,
761            &self.config.lsp,
762            &self.user_dismissed_lsp_languages,
763        );
764        let theme = self.theme.clone();
765        let keybindings_cloned = self.keybindings.read().unwrap().clone(); // Clone the keybindings
766        let chord_state_cloned = self.chord_state.clone(); // Clone the chord state
767
768        // Get update availability info
769        let update_available = self.latest_version().map(|v| v.to_string());
770
771        // Render status bar (hidden when toggled off, or when suggestions/file browser popup is shown)
772        if self.status_bar_visible && !has_suggestions && !has_file_browser {
773            // Get warning level for colored indicator (respects config setting)
774            // LSP warning level is scoped to the current buffer's language
775            let (warning_level, general_warning_count) =
776                if self.config.warnings.show_status_indicator {
777                    let lsp_level = {
778                        use crate::services::async_bridge::LspServerStatus;
779                        let mut level = WarningLevel::None;
780                        for ((lang, _), status) in &self.lsp_server_statuses {
781                            if lang == &current_language {
782                                match status {
783                                    LspServerStatus::Error => {
784                                        level = WarningLevel::Error;
785                                        break;
786                                    }
787                                    LspServerStatus::Starting | LspServerStatus::Initializing => {
788                                        if level != WarningLevel::Error {
789                                            level = WarningLevel::Warning;
790                                        }
791                                    }
792                                    _ => {}
793                                }
794                            }
795                        }
796                        level
797                    };
798                    (lsp_level, self.get_general_warning_count())
799                } else {
800                    (WarningLevel::None, 0)
801                };
802
803            // Compute status bar hover state for styling
804            use crate::view::ui::status_bar::StatusBarHover;
805            let status_bar_hover = match &self.mouse_state.hover_target {
806                Some(HoverTarget::StatusBarLspIndicator) => StatusBarHover::LspIndicator,
807                Some(HoverTarget::StatusBarWarningBadge) => StatusBarHover::WarningBadge,
808                Some(HoverTarget::StatusBarLineEndingIndicator) => {
809                    StatusBarHover::LineEndingIndicator
810                }
811                Some(HoverTarget::StatusBarEncodingIndicator) => StatusBarHover::EncodingIndicator,
812                Some(HoverTarget::StatusBarLanguageIndicator) => StatusBarHover::LanguageIndicator,
813                Some(HoverTarget::StatusBarRemoteIndicator) => StatusBarHover::RemoteIndicator,
814                _ => StatusBarHover::None,
815            };
816
817            let remote_connection = self.connection_display_string();
818
819            // Get session name for display (only in session mode)
820            let session_name = self.session_name().map(|s| s.to_string());
821
822            let active_split = self.effective_active_split();
823            let active_buf = self.active_buffer();
824            let default_cursors = crate::model::cursor::Cursors::new();
825            let status_cursors = self
826                .split_view_states
827                .get(&active_split)
828                .map(|vs| &vs.cursors)
829                .unwrap_or(&default_cursors);
830            let is_read_only = self
831                .buffer_metadata
832                .get(&active_buf)
833                .map(|m| m.read_only)
834                .unwrap_or(false);
835            let is_synthetic_placeholder = self
836                .buffer_metadata
837                .get(&active_buf)
838                .map(|m| m.synthetic_placeholder)
839                .unwrap_or(false);
840            let mut status_ctx = crate::view::ui::status_bar::StatusBarContext {
841                state: self.buffers.get_mut(&active_buf).unwrap(),
842                cursors: status_cursors,
843                status_message: &status_message,
844                plugin_status_message: &plugin_status_message,
845                lsp_status: &lsp_status,
846                lsp_indicator_state,
847                theme: &theme,
848                display_name: &display_name,
849                keybindings: &keybindings_cloned,
850                chord_state: &chord_state_cloned,
851                update_available: update_available.as_deref(),
852                warning_level,
853                general_warning_count,
854                hover: status_bar_hover,
855                remote_connection: remote_connection.as_deref(),
856                session_name: session_name.as_deref(),
857                read_only: is_read_only,
858                remote_state_override: self.remote_indicator_override.as_ref(),
859                is_synthetic_placeholder,
860                // Filled in by `render_status` from the user's
861                // status_bar config; the value here is just a
862                // safe default for the rare path that builds the
863                // ctx but doesn't run `render_status`.
864                remote_indicator_on_bar: false,
865            };
866            let status_bar_layout = StatusBarRenderer::render_status_bar(
867                frame,
868                main_chunks[status_bar_idx],
869                &mut status_ctx,
870                &self.config.editor.status_bar,
871            );
872
873            // Store status bar layout for click detection
874            let status_bar_area = main_chunks[status_bar_idx];
875            self.cached_layout.status_bar_area =
876                Some((status_bar_area.y, status_bar_area.x, status_bar_area.width));
877            self.cached_layout.status_bar_lsp_area = status_bar_layout.lsp_indicator;
878            self.cached_layout.status_bar_warning_area = status_bar_layout.warning_badge;
879            self.cached_layout.status_bar_line_ending_area =
880                status_bar_layout.line_ending_indicator;
881            self.cached_layout.status_bar_encoding_area = status_bar_layout.encoding_indicator;
882            self.cached_layout.status_bar_language_area = status_bar_layout.language_indicator;
883            self.cached_layout.status_bar_message_area = status_bar_layout.message_area;
884            self.cached_layout.status_bar_remote_area = status_bar_layout.remote_indicator;
885        }
886
887        // Render search options bar when in search prompt
888        if show_search_options {
889            // Show "Confirm" option only in replace modes
890            let confirm_each = self.prompt.as_ref().and_then(|p| {
891                if matches!(
892                    p.prompt_type,
893                    PromptType::ReplaceSearch
894                        | PromptType::Replace { .. }
895                        | PromptType::QueryReplaceSearch
896                        | PromptType::QueryReplace { .. }
897                ) {
898                    Some(self.search_confirm_each)
899                } else {
900                    None
901                }
902            });
903
904            // Determine hover state for search options
905            use crate::view::ui::status_bar::SearchOptionsHover;
906            let search_options_hover = match &self.mouse_state.hover_target {
907                Some(HoverTarget::SearchOptionCaseSensitive) => SearchOptionsHover::CaseSensitive,
908                Some(HoverTarget::SearchOptionWholeWord) => SearchOptionsHover::WholeWord,
909                Some(HoverTarget::SearchOptionRegex) => SearchOptionsHover::Regex,
910                Some(HoverTarget::SearchOptionConfirmEach) => SearchOptionsHover::ConfirmEach,
911                _ => SearchOptionsHover::None,
912            };
913
914            let search_options_layout = StatusBarRenderer::render_search_options(
915                frame,
916                main_chunks[search_options_idx],
917                self.search_case_sensitive,
918                self.search_whole_word,
919                self.search_use_regex,
920                confirm_each,
921                &theme,
922                &keybindings_cloned,
923                search_options_hover,
924            );
925            self.cached_layout.search_options_layout = Some(search_options_layout);
926        } else {
927            self.cached_layout.search_options_layout = None;
928        }
929
930        // Render prompt line if active. Overlay prompts (Live Grep)
931        // skip the bottom-row render entirely — they paint their own
932        // input row inside the centred overlay frame, so the user's
933        // editor view stays unobstructed at the bottom.
934        if let Some(prompt) = &prompt {
935            if !prompt.overlay {
936                // Use specialized renderer for file/folder open prompt to show colorized path
937                if matches!(
938                    prompt.prompt_type,
939                    crate::view::prompt::PromptType::OpenFile
940                        | crate::view::prompt::PromptType::SwitchProject
941                ) {
942                    if let Some(file_open_state) = &self.file_open_state {
943                        StatusBarRenderer::render_file_open_prompt(
944                            frame,
945                            main_chunks[prompt_line_idx],
946                            prompt,
947                            file_open_state,
948                            &theme,
949                        );
950                    } else {
951                        StatusBarRenderer::render_prompt(
952                            frame,
953                            main_chunks[prompt_line_idx],
954                            prompt,
955                            &theme,
956                        );
957                    }
958                } else {
959                    StatusBarRenderer::render_prompt(
960                        frame,
961                        main_chunks[prompt_line_idx],
962                        prompt,
963                        &theme,
964                    );
965                }
966            }
967        }
968
969        // Float-overlay preview: load the selected match's file (if
970        // the file changed) and seed the phantom leaf's cursor before
971        // the renderer reaches it. Done before render_prompt_popups
972        // because that path immediately needs the leaf's view state.
973        if self.prompt.as_ref().is_some_and(|p| p.overlay) {
974            self.prepare_overlay_preview();
975        }
976
977        // Render file browser popup or suggestions popup AFTER status bar + prompt,
978        // so they overlay on top of both (fixes bottom border being overwritten by status bar)
979        self.render_prompt_popups(frame, main_chunks[prompt_line_idx], size.width);
980
981        // Render popups from the active buffer state
982        // Clone theme to avoid borrow checker issues with active_state_mut()
983        let theme_clone = self.theme.clone();
984        let hover_target = self.mouse_state.hover_target.clone();
985
986        // Clear popup areas and recalculate
987        self.cached_layout.popup_areas.clear();
988
989        // Collect popup information without holding a mutable borrow
990        let popup_info: Vec<_> = {
991            // Get viewport from active split's SplitViewState
992            let active_split = self.split_manager.active_split();
993            let viewport = self
994                .split_view_states
995                .get(&active_split)
996                .map(|vs| vs.viewport.clone());
997
998            // Get the content_rect for the active split from the cached layout.
999            // This is the absolute screen rect (already accounts for file explorer,
1000            // tab bar, scrollbars, etc.). The gutter is rendered inside this rect,
1001            // so we add gutter_width to get the text content origin.
1002            let content_rect = self
1003                .cached_layout
1004                .split_areas
1005                .iter()
1006                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
1007                .map(|(_, _, rect, _, _, _)| *rect);
1008
1009            let primary_cursor = self
1010                .split_view_states
1011                .get(&active_split)
1012                .map(|vs| *vs.cursors.primary());
1013            let state = self.active_state_mut();
1014            if state.popups.is_visible() {
1015                // Get the primary cursor position for popup positioning
1016                let primary_cursor =
1017                    primary_cursor.unwrap_or_else(|| crate::model::cursor::Cursor::new(0));
1018
1019                // Compute gutter width so we know where text content starts
1020                let gutter_width = viewport
1021                    .as_ref()
1022                    .map(|vp| vp.gutter_width(&state.buffer) as u16)
1023                    .unwrap_or(0);
1024
1025                let cursor_screen_pos = viewport
1026                    .as_ref()
1027                    .map(|vp| vp.cursor_screen_position(&mut state.buffer, &primary_cursor))
1028                    .unwrap_or((0, 0));
1029
1030                // For completion popups, compute the word-start screen position so
1031                // the popup aligns with the beginning of the word being completed,
1032                // not the current cursor position.
1033                let word_start_screen_pos = {
1034                    use crate::primitives::word_navigation::find_completion_word_start;
1035                    let word_start =
1036                        find_completion_word_start(&state.buffer, primary_cursor.position);
1037                    let word_start_cursor = crate::model::cursor::Cursor::new(word_start);
1038                    viewport
1039                        .as_ref()
1040                        .map(|vp| vp.cursor_screen_position(&mut state.buffer, &word_start_cursor))
1041                        .unwrap_or((0, 0))
1042                };
1043
1044                // Use content_rect as the single source of truth for the text
1045                // content area origin. content_rect.x is the split's left edge
1046                // (already past the file explorer), content_rect.y is below the
1047                // tab bar. Adding gutter_width gives us the text content start.
1048                let (base_x, base_y) = content_rect
1049                    .map(|r| (r.x + gutter_width, r.y))
1050                    .unwrap_or((gutter_width, 1));
1051
1052                let cursor_screen_pos =
1053                    (cursor_screen_pos.0 + base_x, cursor_screen_pos.1 + base_y);
1054                let word_start_screen_pos = (
1055                    word_start_screen_pos.0 + base_x,
1056                    word_start_screen_pos.1 + base_y,
1057                );
1058
1059                // Collect popup data
1060                state
1061                    .popups
1062                    .all()
1063                    .iter()
1064                    .enumerate()
1065                    .map(|(popup_idx, popup)| {
1066                        // Use word-start x for completion popups, cursor x for others
1067                        let popup_pos = if popup.kind == crate::view::popup::PopupKind::Completion {
1068                            (word_start_screen_pos.0, cursor_screen_pos.1)
1069                        } else {
1070                            cursor_screen_pos
1071                        };
1072                        let popup_area = popup.calculate_area(size, Some(popup_pos));
1073
1074                        // Track popup area for mouse hit testing
1075                        // Account for description height when calculating the list item area
1076                        let desc_height = popup.description_height();
1077                        let inner_area = if popup.bordered {
1078                            ratatui::layout::Rect {
1079                                x: popup_area.x + 1,
1080                                y: popup_area.y + 1 + desc_height,
1081                                width: popup_area.width.saturating_sub(2),
1082                                height: popup_area.height.saturating_sub(2 + desc_height),
1083                            }
1084                        } else {
1085                            ratatui::layout::Rect {
1086                                x: popup_area.x,
1087                                y: popup_area.y + desc_height,
1088                                width: popup_area.width,
1089                                height: popup_area.height.saturating_sub(desc_height),
1090                            }
1091                        };
1092
1093                        let num_items = match &popup.content {
1094                            crate::view::popup::PopupContent::List { items, .. } => items.len(),
1095                            _ => 0,
1096                        };
1097
1098                        // Calculate total content lines and scrollbar rect
1099                        let total_lines = popup.item_count();
1100                        let visible_lines = inner_area.height as usize;
1101                        let scrollbar_rect = if total_lines > visible_lines && inner_area.width > 2
1102                        {
1103                            Some(ratatui::layout::Rect {
1104                                x: inner_area.x + inner_area.width - 1,
1105                                y: inner_area.y,
1106                                width: 1,
1107                                height: inner_area.height,
1108                            })
1109                        } else {
1110                            None
1111                        };
1112
1113                        (
1114                            popup_idx,
1115                            popup_area,
1116                            inner_area,
1117                            popup.scroll_offset,
1118                            num_items,
1119                            scrollbar_rect,
1120                            total_lines,
1121                        )
1122                    })
1123                    .collect()
1124            } else {
1125                Vec::new()
1126            }
1127        };
1128
1129        // Store popup areas for mouse hit testing
1130        self.cached_layout.popup_areas = popup_info.clone();
1131
1132        // Now render popups
1133        let state = self.active_state_mut();
1134        if state.popups.is_visible() {
1135            for (popup_idx, popup) in state.popups.all().iter().enumerate() {
1136                if let Some((_, popup_area, _, _, _, _, _)) = popup_info.get(popup_idx) {
1137                    popup.render_with_hover(
1138                        frame,
1139                        *popup_area,
1140                        &theme_clone,
1141                        hover_target.as_ref(),
1142                    );
1143                }
1144            }
1145        }
1146
1147        // Render editor-level popups (e.g. plugin action popups) on top of any
1148        // buffer content so they stay visible across buffer switches and over
1149        // virtual buffers (Dashboard, diagnostics) that own the whole split.
1150        // These don't need cursor-relative positioning — they all use absolute
1151        // positions like BottomRight or Centered.
1152        //
1153        // Queue semantics: concurrent action popups stack in `global_popups`,
1154        // but only the top one renders & receives input. Deeper popups
1155        // surface as the top is resolved — the alternative (drawing all at
1156        // the same BottomRight slot) makes them illegible.
1157        self.cached_layout.global_popup_areas.clear();
1158        if let Some(popup) = self.global_popups.top() {
1159            let top_idx = self.global_popups.all().len() - 1;
1160            let popup_area = popup.calculate_area(size, None);
1161            let desc_height = popup.description_height();
1162            let inner_area = if popup.bordered {
1163                ratatui::layout::Rect {
1164                    x: popup_area.x + 1,
1165                    y: popup_area.y + 1 + desc_height,
1166                    width: popup_area.width.saturating_sub(2),
1167                    height: popup_area.height.saturating_sub(2 + desc_height),
1168                }
1169            } else {
1170                ratatui::layout::Rect {
1171                    x: popup_area.x,
1172                    y: popup_area.y + desc_height,
1173                    width: popup_area.width,
1174                    height: popup_area.height.saturating_sub(desc_height),
1175                }
1176            };
1177            let num_items = match &popup.content {
1178                crate::view::popup::PopupContent::List { items, .. } => items.len(),
1179                _ => 0,
1180            };
1181            self.cached_layout.global_popup_areas.push((
1182                top_idx,
1183                popup_area,
1184                inner_area,
1185                popup.scroll_offset,
1186                num_items,
1187            ));
1188            popup.render_with_hover(frame, popup_area, &theme_clone, hover_target.as_ref());
1189        }
1190
1191        // Render menu bar last so dropdown appears on top of all other content
1192        // Update menu context with current editor state
1193        self.update_menu_context();
1194
1195        // Render settings modal (before menu bar so menus can overlay)
1196        // Check visibility first to avoid borrow conflict with dimming
1197        let settings_visible = self
1198            .settings_state
1199            .as_ref()
1200            .map(|s| s.visible)
1201            .unwrap_or(false);
1202        if settings_visible {
1203            // Dim the editor content behind the settings modal
1204            crate::view::dimming::apply_dimming(frame, size);
1205        }
1206        if let Some(ref mut settings_state) = self.settings_state {
1207            if settings_state.visible {
1208                settings_state.update_focus_states();
1209                let settings_layout = crate::view::settings::render_settings(
1210                    frame,
1211                    size,
1212                    settings_state,
1213                    &self.theme,
1214                );
1215                self.cached_layout.settings_layout = Some(settings_layout);
1216            }
1217        }
1218
1219        // Render calibration wizard if active
1220        if let Some(ref wizard) = self.calibration_wizard {
1221            // Dim the editor content behind the wizard modal
1222            crate::view::dimming::apply_dimming(frame, size);
1223            crate::view::calibration_wizard::render_calibration_wizard(
1224                frame,
1225                size,
1226                wizard,
1227                &self.theme,
1228            );
1229        }
1230
1231        // Render keybinding editor if active
1232        if let Some(ref mut kb_editor) = self.keybinding_editor {
1233            crate::view::dimming::apply_dimming(frame, size);
1234            crate::view::keybinding_editor::render_keybinding_editor(
1235                frame,
1236                size,
1237                kb_editor,
1238                &self.theme,
1239            );
1240        }
1241
1242        // Render event debug dialog if active
1243        if let Some(ref debug) = self.event_debug {
1244            // Dim the editor content behind the dialog modal
1245            crate::view::dimming::apply_dimming(frame, size);
1246            crate::view::event_debug::render_event_debug(frame, size, debug, &self.theme);
1247        }
1248
1249        if self.menu_bar_visible {
1250            // Pre-expand DynamicSubmenu items once per registry; without this
1251            // MenuRenderer::render rescans + reparses every theme JSON file
1252            // on every frame.
1253            self.expanded_menus_cache.update(
1254                &self.theme_registry,
1255                &self.menus,
1256                &self.menu_state.themes_dir,
1257            );
1258            let expanded = self.expanded_menus_cache.get().expect("just updated");
1259            let keybindings = self.keybindings.read().unwrap();
1260            self.cached_layout.menu_layout = Some(crate::view::ui::MenuRenderer::render(
1261                frame,
1262                menu_bar_area,
1263                expanded,
1264                &self.menu_state,
1265                &keybindings,
1266                &self.theme,
1267                self.mouse_state.hover_target.as_ref(),
1268                self.config.editor.menu_bar_mnemonics,
1269            ));
1270        } else {
1271            self.cached_layout.menu_layout = None;
1272        }
1273
1274        // Render tab context menu if open
1275        if let Some(ref menu) = self.tab_context_menu {
1276            self.render_tab_context_menu(frame, menu);
1277        }
1278
1279        if let Some(ref menu) = self.file_explorer_context_menu {
1280            self.render_file_explorer_context_menu(frame, menu);
1281        }
1282
1283        // Record non-editor region theme keys for the theme inspector
1284        self.record_non_editor_theme_regions();
1285
1286        // Render theme info popup (Ctrl+Right-Click)
1287        self.render_theme_info_popup(frame);
1288
1289        // Render tab drag drop zone overlay if dragging a tab
1290        if let Some(ref drag_state) = self.mouse_state.dragging_tab {
1291            if drag_state.is_dragging() {
1292                self.render_tab_drop_zone(frame, drag_state);
1293            }
1294        }
1295
1296        // Render software mouse cursor when GPM is active
1297        // GPM can't draw its cursor on the alternate screen buffer used by TUI apps,
1298        // so we draw our own cursor at the tracked mouse position.
1299        // This must happen LAST in the render flow so we can read the already-rendered
1300        // cell content and invert it.
1301        if self.gpm_active {
1302            if let Some((col, row)) = self.mouse_cursor_position {
1303                use ratatui::style::Modifier;
1304
1305                // Only render if within screen bounds
1306                if col < size.width && row < size.height {
1307                    // Get the cell at this position and add REVERSED modifier to invert colors
1308                    let buf = frame.buffer_mut();
1309                    if let Some(cell) = buf.cell_mut((col, row)) {
1310                        cell.set_style(cell.style().add_modifier(Modifier::REVERSED));
1311                    }
1312                }
1313            }
1314        }
1315
1316        // When keyboard capture mode is active, dim all UI elements outside the terminal
1317        // to visually indicate that focus is exclusively on the terminal
1318        if self.keyboard_capture && self.terminal_mode {
1319            // Find the active split's content area
1320            let active_split = self.split_manager.active_split();
1321            let active_split_area = self
1322                .cached_layout
1323                .split_areas
1324                .iter()
1325                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
1326                .map(|(_, _, content_rect, _, _, _)| *content_rect);
1327
1328            if let Some(terminal_area) = active_split_area {
1329                self.apply_keyboard_capture_dimming(frame, terminal_area);
1330            }
1331        }
1332
1333        // Commit the active-split hardware cursor (deferred since
1334        // `render_content`) unless a popup has been drawn over that cell.
1335        // Ratatui draws the hardware caret on top of every cell, so a
1336        // popup cannot hide the cursor by painting cells — the only way
1337        // to hide it is to leave `Frame::cursor_position` as `None`, which
1338        // triggers `Terminal::hide_cursor` at the end of the draw.
1339        //
1340        // When a prompt is active the prompt renderer already placed the
1341        // caret on the prompt line via `frame.set_cursor_position`; don't
1342        // override it with the (now-irrelevant) buffer cursor.
1343        if let Some((cx, cy)) = pending_hardware_cursor {
1344            if self.prompt.is_none() && !self.cursor_obscured_by_overlay(cx, cy) {
1345                frame.set_cursor_position((cx, cy));
1346            }
1347        }
1348
1349        // Convert all colors for terminal capability (256/16 color fallback)
1350        crate::view::color_support::convert_buffer_colors(
1351            frame.buffer_mut(),
1352            self.color_capability,
1353        );
1354
1355        // Frame-buffer animations run last so they mutate the final paint.
1356        self.animations.apply_all(frame.buffer_mut());
1357    }
1358
1359    /// Compare the hardware cursor's screen position to the previous frame's
1360    /// and, if it moved by more than the "jump" threshold, start a
1361    /// `CursorJump` animation from the old to the new on-screen position.
1362    /// Successive jumps cancel the prior animation so trail effects don't
1363    /// pile up.
1364    ///
1365    /// Cross-split and cross-buffer transitions (focus change, tab switch)
1366    /// are also animated — the trail crosses pane separators on its way
1367    /// from one buffer's cursor cell to another's.
1368    ///
1369    /// The threshold is intentionally generous: arrow-key/typing moves
1370    /// (small `dx`/`dy`) must NOT trigger the animation, but search jumps,
1371    /// goto-line/definition, and pane switches (which always cross several
1372    /// rows or many columns) must.
1373    fn maybe_start_cursor_jump_animation(
1374        &mut self,
1375        current_pos: Option<(u16, u16)>,
1376        active_split: crate::model::event::LeafId,
1377    ) {
1378        // Honour the global animations toggle. Tests default to
1379        // `animations = false` so single-tick `render()` calls observe the
1380        // settled buffer instead of a mid-flight trail; users can also
1381        // disable animations entirely from config. The dedicated
1382        // `cursor_jump_animation` toggle suppresses just the cursor-jump
1383        // trail while leaving ambient animations (tab slides, dashboard,
1384        // plugin effects) running.
1385        if !self.config.editor.animations || !self.config.editor.cursor_jump_animation {
1386            self.previous_cursor_screen_pos = current_pos.map(|p| (p, active_split));
1387            return;
1388        }
1389
1390        let Some(current) = current_pos else {
1391            // Cursor is hidden this frame (e.g. prompt has focus). Reset the
1392            // tracker so the re-emerging cursor doesn't animate from a stale
1393            // spot when focus returns to a buffer.
1394            self.previous_cursor_screen_pos = None;
1395            return;
1396        };
1397
1398        let prev_entry = self.previous_cursor_screen_pos;
1399        // Update tracking unconditionally for the next frame.
1400        self.previous_cursor_screen_pos = Some((current, active_split));
1401
1402        let Some((prev, prev_split)) = prev_entry else {
1403            return;
1404        };
1405        if prev == current && prev_split == active_split {
1406            return;
1407        }
1408
1409        let dx = (current.0 as i32 - prev.0 as i32).abs();
1410        let dy = (current.1 as i32 - prev.1 as i32).abs();
1411        // Animate when the cursor crossed split panes, or when it made a
1412        // non-incremental move within the same pane: more than two rows
1413        // vertically, or — for moves that stay within ±2 rows — at
1414        // least 80 columns horizontally. The horizontal threshold is
1415        // generous because typing, arrow keys, word-jump, and Home/End
1416        // on long source lines can all exceed a smaller bound without
1417        // being a genuine "jump".
1418        let crossed_panes = prev_split != active_split;
1419        let row_jump = dy > 2;
1420        let col_jump = dx >= 80;
1421        if !crossed_panes && !row_jump && !col_jump {
1422            return;
1423        }
1424
1425        // Cancel any prior cursor-jump animation so trails don't stack.
1426        if let Some(prev_anim) = self.cursor_jump_animation.take() {
1427            self.animations.cancel(prev_anim);
1428        }
1429
1430        let id = self.animations.start(
1431            // The bounding box is for runner bookkeeping only — CursorJump
1432            // paints at absolute screen coords and ignores `area`.
1433            ratatui::layout::Rect {
1434                x: prev.0.min(current.0),
1435                y: prev.1.min(current.1),
1436                width: dx as u16 + 1,
1437                height: dy as u16 + 1,
1438            },
1439            crate::view::animation::AnimationKind::CursorJump {
1440                from: prev,
1441                to: current,
1442                duration: std::time::Duration::from_millis(140),
1443                cursor_color: self.theme.cursor,
1444                bg_color: self.theme.editor_bg,
1445            },
1446        );
1447        self.cursor_jump_animation = Some(id);
1448    }
1449
1450    /// Returns true if `(x, y)` falls inside any popup-style overlay that
1451    /// was rendered this frame. Used to decide whether the hardware cursor
1452    /// should be shown or hidden so it does not bleed through a popup.
1453    fn cursor_obscured_by_overlay(&self, x: u16, y: u16) -> bool {
1454        let inside = |rect: ratatui::layout::Rect| -> bool {
1455            x >= rect.x
1456                && x < rect.x.saturating_add(rect.width)
1457                && y >= rect.y
1458                && y < rect.y.saturating_add(rect.height)
1459        };
1460
1461        if self
1462            .cached_layout
1463            .popup_areas
1464            .iter()
1465            .any(|entry| inside(entry.1))
1466        {
1467            return true;
1468        }
1469        if self
1470            .cached_layout
1471            .global_popup_areas
1472            .iter()
1473            .any(|entry| inside(entry.1))
1474        {
1475            return true;
1476        }
1477        if let Some((rect, _, _, _)) = self.cached_layout.suggestions_area {
1478            if inside(rect) {
1479                return true;
1480            }
1481        }
1482        if let Some(ref fb) = self.file_browser_layout {
1483            if inside(fb.popup_area) {
1484                return true;
1485            }
1486        }
1487        false
1488    }
1489
1490    /// Render the Quick Open hints line showing available mode prefixes
1491    fn render_quick_open_hints(
1492        frame: &mut Frame,
1493        area: ratatui::layout::Rect,
1494        theme: &crate::view::theme::Theme,
1495    ) {
1496        use ratatui::style::{Modifier, Style};
1497        use ratatui::text::{Line, Span};
1498        use ratatui::widgets::Paragraph;
1499        use rust_i18n::t;
1500
1501        let hints_style = Style::default()
1502            .fg(theme.line_number_fg)
1503            .bg(theme.suggestion_selected_bg)
1504            .add_modifier(Modifier::DIM);
1505        let hints_text = t!("quick_open.mode_hints");
1506        // Left-align with small margin
1507        let left_margin = 2;
1508        let hints_width = crate::primitives::display_width::str_width(&hints_text);
1509        let mut spans = Vec::new();
1510        spans.push(Span::styled(" ".repeat(left_margin), hints_style));
1511        spans.push(Span::styled(hints_text.to_string(), hints_style));
1512        let remaining = (area.width as usize).saturating_sub(left_margin + hints_width);
1513        spans.push(Span::styled(" ".repeat(remaining), hints_style));
1514
1515        let paragraph = Paragraph::new(Line::from(spans));
1516        frame.render_widget(paragraph, area);
1517    }
1518
1519    /// Apply dimming effect to UI elements outside the focused terminal area
1520    /// This visually indicates that keyboard capture mode is active
1521    fn apply_keyboard_capture_dimming(
1522        &self,
1523        frame: &mut Frame,
1524        terminal_area: ratatui::layout::Rect,
1525    ) {
1526        let size = frame.area();
1527        crate::view::dimming::apply_dimming_excluding(frame, size, Some(terminal_area));
1528    }
1529
1530    /// Render file browser or suggestions popup as overlay above the prompt line.
1531    /// Called after status bar + prompt so the popup draws on top of both.
1532    fn render_prompt_popups(
1533        &mut self,
1534        frame: &mut Frame,
1535        prompt_area: ratatui::layout::Rect,
1536        width: u16,
1537    ) {
1538        let Some(prompt) = &self.prompt else { return };
1539
1540        // Overlay prompts (Live Grep, issue #1796) get a dedicated
1541        // centred floating frame instead of the bottom-anchored popup.
1542        if prompt.overlay {
1543            let frame_area = frame.area();
1544            self.render_overlay_prompt(frame, frame_area);
1545            return;
1546        }
1547
1548        if matches!(
1549            prompt.prompt_type,
1550            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
1551        ) {
1552            let Some(file_open_state) = &mut self.file_open_state else {
1553                return;
1554            };
1555            let max_height = prompt_area.y.saturating_sub(1).min(20);
1556            let popup_area = ratatui::layout::Rect {
1557                x: 0,
1558                y: prompt_area.y.saturating_sub(max_height),
1559                width,
1560                height: max_height,
1561            };
1562            let keybindings = self.keybindings.read().unwrap();
1563            self.file_browser_layout = crate::view::ui::FileBrowserRenderer::render(
1564                frame,
1565                popup_area,
1566                file_open_state,
1567                &self.theme,
1568                &self.mouse_state.hover_target,
1569                Some(&*keybindings),
1570            );
1571            return;
1572        }
1573
1574        if prompt.suggestions.is_empty() {
1575            return;
1576        }
1577
1578        let suggestion_count = prompt.suggestions.len().min(10);
1579        let is_quick_open = prompt.prompt_type == crate::view::prompt::PromptType::QuickOpen;
1580        let hints_height: u16 = if is_quick_open { 1 } else { 0 };
1581        let height = suggestion_count as u16 + 2 + hints_height;
1582
1583        let suggestions_area = ratatui::layout::Rect {
1584            x: 0,
1585            y: prompt_area.y.saturating_sub(height),
1586            width,
1587            height: height - hints_height,
1588        };
1589
1590        frame.render_widget(ratatui::widgets::Clear, suggestions_area);
1591
1592        // Adjust the prompt's scroll position to keep the selected item
1593        // visible, scrolling the minimum amount required.
1594        if let Some(prompt) = self.prompt.as_mut() {
1595            prompt.ensure_selected_visible();
1596        }
1597        let Some(prompt) = &self.prompt else { return };
1598
1599        self.cached_layout.suggestions_area = SuggestionsRenderer::render_with_hover(
1600            frame,
1601            suggestions_area,
1602            prompt,
1603            &self.theme,
1604            self.mouse_state.hover_target.as_ref(),
1605            true,
1606        );
1607        if self.cached_layout.suggestions_area.is_some() {
1608            self.cached_layout.suggestions_outer_area = Some(suggestions_area);
1609        }
1610
1611        if is_quick_open {
1612            let hints_area = ratatui::layout::Rect {
1613                x: 0,
1614                y: prompt_area.y.saturating_sub(hints_height),
1615                width,
1616                height: hints_height,
1617            };
1618            frame.render_widget(ratatui::widgets::Clear, hints_area);
1619            Self::render_quick_open_hints(frame, hints_area, &self.theme);
1620        }
1621    }
1622
1623    /// Resolve the overlay's currently-selected match into a real
1624    /// `Buffer` parked in a phantom `LeafId`, so the preview pane can
1625    /// reuse the regular per-leaf renderer (with syntax highlighting,
1626    /// gutter, scrollbars, folding). No-op when the prompt has no
1627    /// selection or its label is not a `path:line[:col]` triple.
1628    fn prepare_overlay_preview(&mut self) {
1629        use crate::input::quick_open::parse_path_line_col;
1630
1631        let (path_str, line, col) = {
1632            let Some(prompt) = self.prompt.as_ref() else {
1633                return;
1634            };
1635            let Some(idx) = prompt.selected_suggestion else {
1636                return;
1637            };
1638            let Some(s) = prompt.suggestions.get(idx) else {
1639                return;
1640            };
1641            // Suggestions emitted by the Finder library use `value` as
1642            // an opaque index; the parseable label lives in `text`.
1643            // Resume-replay is the inverse: `value` carries the full
1644            // path:line:col triple.
1645            let from_text = parse_path_line_col(&s.text);
1646            if !from_text.0.is_empty() && from_text.1.is_some() {
1647                from_text
1648            } else if let Some(v) = s.value.as_deref() {
1649                parse_path_line_col(v)
1650            } else {
1651                from_text
1652            }
1653        };
1654        if path_str.is_empty() {
1655            return;
1656        }
1657        let line = line.unwrap_or(1).saturating_sub(1);
1658        let col = col.unwrap_or(1).saturating_sub(1);
1659
1660        // Resolve relative to the working directory.
1661        let path_buf = std::path::PathBuf::from(&path_str);
1662        let abs_path = if path_buf.is_absolute() {
1663            path_buf
1664        } else {
1665            self.working_dir.join(&path_buf)
1666        };
1667        // Canonicalize for buffer-dedup parity with open_file_no_focus.
1668        let abs_path = self
1669            .authority
1670            .filesystem
1671            .canonicalize(&abs_path)
1672            .unwrap_or(abs_path);
1673
1674        // If the standalone state already targets this path, just
1675        // re-seed the cursor and skip the file-load roundtrip.
1676        let already_target = self.overlay_preview_state.as_ref().is_some_and(|st| {
1677            self.buffers
1678                .get(&st.buffer_id)
1679                .and_then(|s| s.buffer.file_path())
1680                .is_some_and(|p| p == abs_path.as_path())
1681        });
1682
1683        let buffer_id = if already_target {
1684            self.overlay_preview_state.as_ref().unwrap().buffer_id
1685        } else {
1686            // Snapshot whether this path was already known so we can
1687            // tell "I just loaded it for preview" from "the user had
1688            // it open" — only the former gets cleaned up on close.
1689            let was_open = self
1690                .buffers
1691                .iter()
1692                .any(|(_, s)| s.buffer.file_path() == Some(abs_path.as_path()));
1693            // Capture the active split so we can undo the side
1694            // effects of `open_file_no_focus` (it adds the buffer to
1695            // the active split's tabs and may switch its active
1696            // buffer to the loaded file).
1697            let source_split = self.split_manager.active_split();
1698            // `open_file_for_preview` always allocates a fresh buffer
1699            // — never repurposes the "no name" empty buffer the user
1700            // is currently looking at — so the background view stays
1701            // intact while we cycle through preview results.
1702            let buffer_id = match self.open_file_for_preview(abs_path.as_path()) {
1703                Ok(id) => id,
1704                Err(_e) => return,
1705            };
1706            if !was_open {
1707                if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
1708                    meta.hidden_from_tabs = true;
1709                }
1710                // Drop the buffer from every split's `open_buffers`
1711                // list so it doesn't surface as a tab anywhere. The
1712                // phantom buffer is rendered exclusively via the
1713                // overlay's standalone view-state — it doesn't need
1714                // to be in `open_buffers`.
1715                let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
1716                for leaf_id in leaf_ids {
1717                    if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
1718                        view_state.remove_buffer(buffer_id);
1719                    }
1720                }
1721                // open_file_no_focus may have switched the active
1722                // buffer of the source split. Restore it.
1723                if let Some(source_state) = self.split_view_states.get_mut(&source_split) {
1724                    if source_state.active_buffer == buffer_id {
1725                        let preview_loaded: std::collections::HashSet<BufferId> = self
1726                            .overlay_preview_state
1727                            .as_ref()
1728                            .map(|st| st.loaded_buffers.clone())
1729                            .unwrap_or_default();
1730                        let fallback = source_state
1731                            .open_buffers
1732                            .iter()
1733                            .find_map(|t| t.as_buffer())
1734                            .or_else(|| {
1735                                self.buffers
1736                                    .keys()
1737                                    .copied()
1738                                    .find(|b| *b != buffer_id && !preview_loaded.contains(b))
1739                            });
1740                        if let Some(fb) = fallback {
1741                            source_state.switch_buffer(fb);
1742                            self.split_manager.set_split_buffer(source_split, fb);
1743                        }
1744                    }
1745                }
1746                self.split_manager.set_active_split(source_split);
1747            }
1748            buffer_id
1749        };
1750
1751        // Build (or update) the standalone preview state. Held off
1752        // `split_view_states` so cross-cutting iteration never touches
1753        // it.
1754        let need_init = self.overlay_preview_state.is_none();
1755        if need_init {
1756            let mut view_state = crate::view::split::SplitViewState::with_buffer(
1757                self.terminal_width,
1758                self.terminal_height,
1759                buffer_id,
1760            );
1761            view_state.apply_config_defaults(
1762                self.config.editor.line_numbers,
1763                self.config.editor.highlight_current_line,
1764                self.resolve_line_wrap_for_buffer(buffer_id),
1765                self.config.editor.wrap_indent,
1766                self.resolve_wrap_column_for_buffer(buffer_id),
1767                self.config.editor.rulers.clone(),
1768            );
1769            let mut loaded_buffers = std::collections::HashSet::new();
1770            // Whether this *first* preview buffer was newly loaded.
1771            // The pre-existing case skips the `was_open` branch so
1772            // we re-derive it from buffer_metadata: a buffer with
1773            // hidden_from_tabs=true that we just touched is one we
1774            // owned. Simpler: track via the existing-target check:
1775            // if `already_target` was false above, the buffer was
1776            // either pre-open (we left meta alone) or freshly
1777            // loaded (we set hidden_from_tabs=true). Re-check.
1778            if let Some(meta) = self.buffer_metadata.get(&buffer_id) {
1779                if meta.hidden_from_tabs {
1780                    loaded_buffers.insert(buffer_id);
1781                }
1782            }
1783            self.overlay_preview_state = Some(crate::app::types::OverlayPreviewState {
1784                buffer_id,
1785                view_state,
1786                loaded_buffers,
1787            });
1788        } else if let Some(state) = self.overlay_preview_state.as_mut() {
1789            if state.buffer_id != buffer_id {
1790                state.view_state.switch_buffer(buffer_id);
1791                state.buffer_id = buffer_id;
1792                if let Some(meta) = self.buffer_metadata.get(&buffer_id) {
1793                    if meta.hidden_from_tabs {
1794                        state.loaded_buffers.insert(buffer_id);
1795                    }
1796                }
1797            }
1798        }
1799
1800        // Set the cursor to the match position and centre the line.
1801        let byte_offset = self
1802            .buffers
1803            .get(&buffer_id)
1804            .map(|s| {
1805                s.buffer
1806                    .position_to_offset(crate::model::piece_tree::Position { line, column: col })
1807            })
1808            .unwrap_or(0);
1809        let line_start = self
1810            .buffers
1811            .get(&buffer_id)
1812            .and_then(|s| s.buffer.line_start_offset(line))
1813            .unwrap_or(byte_offset);
1814        if let Some(state) = self.overlay_preview_state.as_mut() {
1815            state.view_state.cursors.primary_mut().position = byte_offset;
1816            let h = state.view_state.viewport.height.max(1) as usize;
1817            let half = h / 2;
1818            let target_top_line = line.saturating_sub(half);
1819            let top_byte = self
1820                .buffers
1821                .get(&buffer_id)
1822                .and_then(|s| s.buffer.line_start_offset(target_top_line))
1823                .unwrap_or(line_start);
1824            state.view_state.viewport.top_byte = top_byte;
1825        }
1826    }
1827
1828    /// Render the active prompt as a centred floating overlay
1829    /// (issue #1796). Layout, top-down inside the overlay frame:
1830    ///
1831    /// ```text
1832    /// ┌─ Live Grep ──────────────────────────────────[Esc to close]┐
1833    /// │ Search: split_active|                           12 / 142    │  ← input row
1834    /// │ ─────────────────────────────────────────────────────────── │
1835    /// │  src/view/split.rs:1117  pub fn split_active(    │ preview │  ← results
1836    /// │  src/view/split.rs:1123  self.split_active_pos…  │  pane   │     (+ optional
1837    /// │ …                                                │         │      preview)
1838    /// └────────────────────────────────────────────────────────────┘
1839    /// ```
1840    ///
1841    /// The overlay does *not* mutate the split tree; it is a pure
1842    /// `ratatui` overdraw, so dismissing leaves the user's underlying
1843    /// layout exactly as it was (the issue-#1796 acceptance test).
1844    fn render_overlay_prompt(&mut self, frame: &mut Frame, area: ratatui::layout::Rect) {
1845        use crate::view::popup::PopupPosition;
1846        use ratatui::layout::Rect;
1847        use ratatui::style::{Modifier, Style};
1848        use ratatui::text::{Line, Span};
1849        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1850
1851        // Compute the overlay rect via the same percentage logic the
1852        // popup engine uses. 80% × 80% of the terminal, centred.
1853        let overlay_pos = PopupPosition::CenteredOverlay {
1854            width_pct: 80,
1855            height_pct: 80,
1856        };
1857        let overlay_rect = match overlay_pos {
1858            PopupPosition::CenteredOverlay {
1859                width_pct,
1860                height_pct,
1861            } => {
1862                let w_pct = width_pct.clamp(1, 100) as u32;
1863                let h_pct = height_pct.clamp(1, 100) as u32;
1864                let w = ((area.width as u32 * w_pct) / 100) as u16;
1865                let h = ((area.height as u32 * h_pct) / 100) as u16;
1866                let w = w.max(20).min(area.width);
1867                let h = h.max(8).min(area.height);
1868                Rect {
1869                    x: (area.width.saturating_sub(w)) / 2,
1870                    y: (area.height.saturating_sub(h)) / 2,
1871                    width: w,
1872                    height: h,
1873                }
1874            }
1875            _ => unreachable!(),
1876        };
1877
1878        // Snapshot view-relevant state before any mutable borrows.
1879        let theme = self.theme.clone();
1880        // The suggestion list inside the overlay can be ~30 rows
1881        // tall on a typical terminal. Pass the *actual* visible
1882        // count to `ensure_selected_visible_within` so the scroll
1883        // offset only advances when the selection genuinely passes
1884        // the bottom of the visible window — not when it crosses
1885        // the bottom-popup default cap of `MAX_VISIBLE_SUGGESTIONS`
1886        // (= 10), which would scroll prematurely.
1887        //
1888        // Geometry: overlay frame border (2) + input row (1) +
1889        // optional toolbar row (1, when `prompt.title` is non-empty)
1890        // + separator (1). The suggestions popup is rendered
1891        // borderless inside the overlay (the outer frame already
1892        // provides a border, so adding a nested one creates a
1893        // double-frame). Inner content height = overlay.height -
1894        // chrome.
1895        let toolbar_visible = self
1896            .prompt
1897            .as_ref()
1898            .map(|p| !p.title.is_empty())
1899            .unwrap_or(false);
1900        let chrome_rows: usize = 4 + if toolbar_visible { 1 } else { 0 };
1901        let suggestions_visible_rows = (overlay_rect.height as usize).saturating_sub(chrome_rows);
1902        if let Some(prompt) = self.prompt.as_mut() {
1903            prompt.ensure_selected_visible_within(suggestions_visible_rows);
1904        }
1905        let Some(prompt) = self.prompt.as_ref() else {
1906            return;
1907        };
1908        let prompt = prompt.clone();
1909
1910        // Dim everything outside the overlay rect so the user's
1911        // focus visibly belongs to the popup. Reuses the same RGB-
1912        // darkening pass the Settings modal uses (`view::dimming`)
1913        // — Modifier::DIM alone is barely visible on most terminals.
1914        crate::view::dimming::apply_dimming_excluding(frame, frame.area(), Some(overlay_rect));
1915
1916        // Clear and frame. Plugin-owned prompts can publish their
1917        // own title via `editor.setPromptTitle(...)`; falls back to
1918        // " Live Grep " plus shortcut hints when unset (so a
1919        // Resume-replay prompt and freshly-opened plugin prompt look
1920        // similar even though they take different code paths).
1921        frame.render_widget(Clear, overlay_rect);
1922        let default_title: Vec<fresh_core::api::StyledText> = {
1923            // Mirrors `updateOverlayTitle` in live_grep.ts (kept in
1924            // sync deliberately so a Resume-replay overlay and a
1925            // freshly-opened plugin overlay look identical). The
1926            // input row's prefix already says "Live grep:", so the
1927            // frame title doesn't repeat the feature name — it
1928            // shows shortcut hints only. `resume_live_grep` is
1929            // intentionally NOT shown here; that shortcut only
1930            // matters once the overlay is closed.
1931            use crate::input::keybindings::KeyContext;
1932            use fresh_core::api::{OverlayColorSpec, OverlayOptions, StyledText};
1933            let keybindings = self.keybindings.read().unwrap();
1934            let mut hints: Vec<(String, &str)> = Vec::new();
1935            if let Some(k) = keybindings
1936                .find_keybinding_for_action("cycle_live_grep_provider", KeyContext::Prompt)
1937            {
1938                hints.push((k, "switch grep provider"));
1939            }
1940            if let Some(k) = keybindings
1941                .find_keybinding_for_action("live_grep_export_quickfix", KeyContext::Prompt)
1942            {
1943                hints.push((k, "save matches"));
1944            }
1945            if hints.is_empty() {
1946                Vec::new()
1947            } else {
1948                let hint_style = Some(OverlayOptions {
1949                    fg: Some(OverlayColorSpec::ThemeKey("ui.help_key_fg".into())),
1950                    ..OverlayOptions::default()
1951                });
1952                let sep_style = Some(OverlayOptions {
1953                    fg: Some(OverlayColorSpec::ThemeKey("ui.popup_border_fg".into())),
1954                    ..OverlayOptions::default()
1955                });
1956                let mut segs: Vec<StyledText> = Vec::new();
1957                for (i, (k, verb)) in hints.into_iter().enumerate() {
1958                    if i > 0 {
1959                        segs.push(StyledText {
1960                            text: " · ".into(),
1961                            style: sep_style.clone(),
1962                        });
1963                    }
1964                    segs.push(StyledText {
1965                        text: k,
1966                        style: hint_style.clone(),
1967                    });
1968                    segs.push(StyledText {
1969                        text: format!(" {verb}"),
1970                        style: None,
1971                    });
1972                }
1973                segs
1974            }
1975        };
1976        let title_segs: &[fresh_core::api::StyledText] = if prompt.title.is_empty() {
1977            &default_title
1978        } else {
1979            &prompt.title
1980        };
1981        let normal_title_style = Style::default()
1982            .fg(theme.prompt_fg)
1983            .add_modifier(Modifier::BOLD);
1984        let title_spans: Vec<Span> = title_segs
1985            .iter()
1986            .map(|seg| {
1987                let style = match &seg.style {
1988                    Some(opts) => Self::resolve_overlay_style(opts, &theme),
1989                    None => normal_title_style,
1990                };
1991                Span::styled(seg.text.clone(), style)
1992            })
1993            .collect();
1994        let block = Block::default()
1995            .borders(Borders::ALL)
1996            .border_style(Style::default().fg(theme.popup_border_fg))
1997            .style(Style::default().bg(theme.suggestion_bg));
1998        let inner = block.inner(overlay_rect);
1999        frame.render_widget(block, overlay_rect);
2000
2001        if inner.height == 0 || inner.width == 0 {
2002            return;
2003        }
2004
2005        // Decide whether to split the inner area into results | preview.
2006        // Below ~120 cols, stack results-only (preview hidden — see
2007        // design doc §5 "preview pane size when terminal is narrow").
2008        let preview_min_cols: u16 = 120;
2009        let show_preview = overlay_rect.width >= preview_min_cols;
2010        let (results_area, preview_area) = if show_preview {
2011            let results_w = inner.width / 2;
2012            (
2013                Rect {
2014                    x: inner.x,
2015                    y: inner.y,
2016                    width: results_w,
2017                    height: inner.height,
2018                },
2019                Some(Rect {
2020                    x: inner.x + results_w,
2021                    y: inner.y,
2022                    width: inner.width - results_w,
2023                    height: inner.height,
2024                }),
2025            )
2026        } else {
2027            (inner, None)
2028        };
2029
2030        // Top row of `results_area` is the prompt input.
2031        let input_row = Rect {
2032            x: results_area.x,
2033            y: results_area.y,
2034            width: results_area.width,
2035            height: 1,
2036        };
2037        // Use the editor's default bg for the input row so it
2038        // visually reads as an editable text field (the popup bg
2039        // is reserved for non-editable chrome — toolbar, results
2040        // list). Both colours come from theme keys, no hardcoded
2041        // RGB.
2042        let input_style = Style::default().fg(theme.prompt_fg).bg(theme.editor_bg);
2043        let count_str = if prompt.suggestions.is_empty() {
2044            String::new()
2045        } else {
2046            format!(
2047                "{} / {}",
2048                prompt.selected_suggestion.map(|i| i + 1).unwrap_or(0),
2049                prompt.suggestions.len()
2050            )
2051        };
2052        use crate::primitives::display_width::str_width;
2053        let count_w = str_width(&count_str);
2054        // Reserve one trailing column so the count doesn't sit
2055        // flush against the right border.
2056        let right_gap: usize = if count_w > 0 { 1 } else { 0 };
2057        let visible_input_width = (results_area.width as usize).saturating_sub(count_w + right_gap);
2058        let truncated_input: String = prompt
2059            .input
2060            .chars()
2061            .take(visible_input_width.saturating_sub(str_width(&prompt.message)))
2062            .collect();
2063        // Pad between the typed input and the count so the count
2064        // is right-aligned (with `right_gap` empty cols at the
2065        // very edge), independent of how much the user has typed.
2066        let used = str_width(&prompt.message) + str_width(&truncated_input) + count_w;
2067        let pad = (results_area.width as usize).saturating_sub(used + right_gap);
2068        let line = Line::from(vec![
2069            Span::styled(prompt.message.clone(), input_style),
2070            Span::styled(truncated_input, input_style),
2071            Span::styled(" ".repeat(pad), input_style),
2072            Span::styled(
2073                count_str,
2074                Style::default()
2075                    .fg(theme.popup_border_fg)
2076                    .bg(theme.editor_bg),
2077            ),
2078        ]);
2079        frame.render_widget(Paragraph::new(line).style(input_style), input_row);
2080
2081        // Cursor position on the input row.
2082        let cursor_x = (str_width(&prompt.message)
2083            + str_width(&prompt.input[..prompt.cursor_pos.min(prompt.input.len())]))
2084            as u16;
2085        if cursor_x < input_row.width {
2086            frame.set_cursor_position((input_row.x + cursor_x, input_row.y));
2087        }
2088
2089        // Optional toolbar row (the styled segments the plugin set
2090        // via setPromptTitle, e.g. "Provider: rg · Alt+P switch
2091        // grep provider · …"). Sits between the input row and the
2092        // separator so the user sees feature-scoped controls right
2093        // under what they're typing — not on the frame border
2094        // where shortcut hints get visually lost.
2095        let toolbar_h: u16 = if toolbar_visible { 1 } else { 0 };
2096        if toolbar_visible && results_area.height >= 2 {
2097            let toolbar = Rect {
2098                x: results_area.x,
2099                y: results_area.y + 1,
2100                width: results_area.width,
2101                height: 1,
2102            };
2103            frame.render_widget(
2104                Paragraph::new(Line::from(title_spans))
2105                    .style(Style::default().bg(theme.suggestion_bg)),
2106                toolbar,
2107            );
2108        }
2109
2110        // Separator row.
2111        if results_area.height >= 2 + toolbar_h {
2112            let sep = Rect {
2113                x: results_area.x,
2114                y: results_area.y + 1 + toolbar_h,
2115                width: results_area.width,
2116                height: 1,
2117            };
2118            let sep_style = Style::default()
2119                .fg(theme.popup_border_fg)
2120                .bg(theme.suggestion_bg);
2121            let sep_text = "─".repeat(results_area.width as usize);
2122            frame.render_widget(Paragraph::new(sep_text).style(sep_style), sep);
2123        }
2124
2125        // Suggestions list fills the rest of `results_area`. Carve
2126        // off the rightmost 1-column lane for a scrollbar so the
2127        // user can see how far through the result set the selection
2128        // is — important when the visible area only fits ~30 of
2129        // 100+ matches. Only carve when the result set actually
2130        // exceeds the visible rows; otherwise the scrollbar is
2131        // visual noise.
2132        let chrome_above_list: u16 = 2 + toolbar_h;
2133        if results_area.height > chrome_above_list {
2134            // No `-2` for popup-own-border — we render the
2135            // borderless variant below since the overlay frame is
2136            // already a border.
2137            let inner_rows = (results_area.height - chrome_above_list) as usize;
2138            let needs_scrollbar = prompt.suggestions.len() > inner_rows.max(1);
2139            let scrollbar_w: u16 = if needs_scrollbar { 1 } else { 0 };
2140            let list_area = Rect {
2141                x: results_area.x,
2142                y: results_area.y + chrome_above_list,
2143                width: results_area.width.saturating_sub(scrollbar_w),
2144                height: results_area.height - chrome_above_list,
2145            };
2146            self.cached_layout.suggestions_area = SuggestionsRenderer::render_with_hover(
2147                frame,
2148                list_area,
2149                &prompt,
2150                &theme,
2151                self.mouse_state.hover_target.as_ref(),
2152                false,
2153            );
2154            if self.cached_layout.suggestions_area.is_some() {
2155                self.cached_layout.suggestions_outer_area = Some(list_area);
2156            }
2157            // Render the scrollbar in the carved lane. Reuses the
2158            // shared `view::ui::scrollbar` widget so thumb sizing
2159            // and theme colours match scrollbars elsewhere in the
2160            // editor (split rendering, file explorer, …).
2161            if needs_scrollbar {
2162                use crate::view::ui::scrollbar::{
2163                    render_scrollbar, ScrollbarColors, ScrollbarState,
2164                };
2165                // Scrollbar rect aligns with the borderless
2166                // suggestions list — same y/height as the list itself
2167                // since there's no popup-own border to skip.
2168                let scrollbar_rect = Rect {
2169                    x: results_area.x + results_area.width - 1,
2170                    y: list_area.y,
2171                    width: 1,
2172                    height: list_area.height,
2173                };
2174                let state = ScrollbarState::new(
2175                    prompt.suggestions.len(),
2176                    inner_rows.max(1),
2177                    prompt.scroll_offset,
2178                );
2179                render_scrollbar(
2180                    frame,
2181                    scrollbar_rect,
2182                    &state,
2183                    &ScrollbarColors::from_theme(&theme),
2184                );
2185                // Cache the rect for mouse hit testing in
2186                // `mouse_input.rs::handle_click_prompt_scrollbar`.
2187                self.cached_layout.suggestions_scrollbar_rect = Some(scrollbar_rect);
2188            } else {
2189                self.cached_layout.suggestions_scrollbar_rect = None;
2190            }
2191        } else {
2192            self.cached_layout.suggestions_scrollbar_rect = None;
2193        }
2194
2195        // Right-half preview pane: a real Buffer rendered via the
2196        // same per-leaf pipeline regular splits use. Buffer + cursor
2197        // are already seeded by `prepare_overlay_preview` (called
2198        // earlier in the render flow). Borrows are split here so we
2199        // can hand out independent `&mut` references to the
2200        // renderer's internals without going back through `&mut self`.
2201        if let Some(preview_rect) = preview_area {
2202            // Frame the preview area first (vertical separator) so
2203            // the renderer fills the inner rect.
2204            use ratatui::widgets::{Block, Borders, Clear};
2205            frame.render_widget(Clear, preview_rect);
2206            let block = Block::default()
2207                .borders(Borders::LEFT)
2208                .border_style(Style::default().fg(theme.popup_border_fg))
2209                .style(Style::default().bg(theme.suggestion_bg));
2210            let inner = block.inner(preview_rect);
2211            frame.render_widget(block, preview_rect);
2212
2213            if inner.height > 0 && inner.width > 0 {
2214                // Snapshot scalar config values up front so the
2215                // mutable-borrow split below has minimal scope.
2216                // AnsiBackground isn't Clone, so it's taken as a
2217                // borrow; Rust permits disjoint-field splitting
2218                // between `&self.ansi_background` and the `&mut`
2219                // accesses below because they touch distinct fields.
2220                let bg_fade = self.background_fade;
2221                let estimated_line_length = self.config.editor.estimated_line_length;
2222                let highlight_context_bytes = self.config.editor.highlight_context_bytes;
2223                let relative_line_numbers = self.config.editor.relative_line_numbers;
2224                let use_terminal_bg = self.config.editor.use_terminal_bg;
2225                let session_mode = self.session_mode || !self.software_cursor_only;
2226                let software_cursor_only = self.software_cursor_only;
2227                let diagnostics_inline_text = self.config.editor.diagnostics_inline_text;
2228                let show_tilde = false; // preview hides tilde markers
2229                let highlight_current_column = self.config.editor.highlight_current_column;
2230                let screen_width = frame.area().width;
2231
2232                let ansi_ref = self.ansi_background.as_ref();
2233                let buffers = &mut self.buffers;
2234                let event_logs = &mut self.event_logs;
2235                let cell_theme_map = &mut self.cached_layout.cell_theme_map;
2236                let Some(preview_state) = self.overlay_preview_state.as_mut() else {
2237                    return;
2238                };
2239                preview_state
2240                    .view_state
2241                    .viewport
2242                    .resize(inner.width, inner.height);
2243                let buffer_id = preview_state.buffer_id;
2244
2245                if let Some(state) = buffers.get_mut(&buffer_id) {
2246                    // Deref the SplitViewState once to a concrete
2247                    // `&mut BufferViewState` so disjoint field
2248                    // splits (`viewport` + `folds`) are visible
2249                    // to the borrow checker.
2250                    let buf_state = preview_state.view_state.active_state_mut();
2251                    let cursors = buf_state.cursors.clone();
2252                    let view_mode = buf_state.view_mode.clone();
2253                    let compose_width = buf_state.compose_width;
2254                    let compose_column_guides = buf_state.compose_column_guides.clone();
2255                    let view_transform = buf_state.view_transform.clone();
2256                    let rulers = buf_state.rulers.clone();
2257                    let show_line_numbers = buf_state.show_line_numbers;
2258                    let highlight_current_line = buf_state.highlight_current_line;
2259                    let viewport_ref = &mut buf_state.viewport;
2260                    let folds_ref = &mut buf_state.folds;
2261                    let event_log = event_logs.get_mut(&buffer_id);
2262                    let _ = crate::view::ui::SplitRenderer::render_phantom_leaf(
2263                        frame,
2264                        state,
2265                        &cursors,
2266                        viewport_ref,
2267                        folds_ref,
2268                        event_log,
2269                        inner,
2270                        &theme,
2271                        ansi_ref,
2272                        bg_fade,
2273                        view_mode,
2274                        compose_width,
2275                        compose_column_guides,
2276                        view_transform,
2277                        estimated_line_length,
2278                        highlight_context_bytes,
2279                        buffer_id,
2280                        relative_line_numbers,
2281                        use_terminal_bg,
2282                        session_mode,
2283                        software_cursor_only,
2284                        &rulers,
2285                        show_line_numbers,
2286                        highlight_current_line,
2287                        diagnostics_inline_text,
2288                        show_tilde,
2289                        highlight_current_column,
2290                        cell_theme_map,
2291                        screen_width,
2292                    );
2293                }
2294            }
2295        }
2296    }
2297
2298    /// Render hover highlights for interactive elements (separators, scrollbars)
2299    pub(super) fn render_hover_highlights(&self, frame: &mut Frame) {
2300        use ratatui::style::Style;
2301        use ratatui::text::Span;
2302        use ratatui::widgets::Paragraph;
2303
2304        match &self.mouse_state.hover_target {
2305            Some(HoverTarget::SplitSeparator(split_id, direction)) => {
2306                // Highlight the separator with hover color
2307                for (sid, dir, x, y, length) in &self.cached_layout.separator_areas {
2308                    if sid == split_id && dir == direction {
2309                        let hover_style = Style::default().fg(self.theme.split_separator_hover_fg);
2310                        match dir {
2311                            SplitDirection::Horizontal => {
2312                                let line_text = "─".repeat(*length as usize);
2313                                let paragraph =
2314                                    Paragraph::new(Span::styled(line_text, hover_style));
2315                                frame.render_widget(
2316                                    paragraph,
2317                                    ratatui::layout::Rect::new(*x, *y, *length, 1),
2318                                );
2319                            }
2320                            SplitDirection::Vertical => {
2321                                for offset in 0..*length {
2322                                    let paragraph = Paragraph::new(Span::styled("│", hover_style));
2323                                    frame.render_widget(
2324                                        paragraph,
2325                                        ratatui::layout::Rect::new(*x, y + offset, 1, 1),
2326                                    );
2327                                }
2328                            }
2329                        }
2330                    }
2331                }
2332            }
2333            Some(HoverTarget::ScrollbarThumb(split_id)) => {
2334                // Highlight scrollbar thumb
2335                for (sid, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
2336                    &self.cached_layout.split_areas
2337                {
2338                    if sid == split_id {
2339                        let hover_style = Style::default().bg(self.theme.scrollbar_thumb_hover_fg);
2340                        for row_offset in *thumb_start..*thumb_end {
2341                            let paragraph = Paragraph::new(Span::styled(" ", hover_style));
2342                            frame.render_widget(
2343                                paragraph,
2344                                ratatui::layout::Rect::new(
2345                                    scrollbar_rect.x,
2346                                    scrollbar_rect.y + row_offset as u16,
2347                                    1,
2348                                    1,
2349                                ),
2350                            );
2351                        }
2352                    }
2353                }
2354            }
2355            Some(HoverTarget::ScrollbarTrack(split_id, hovered_row)) => {
2356                // Highlight only the hovered cell on the scrollbar track
2357                for (sid, _buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
2358                    &self.cached_layout.split_areas
2359                {
2360                    if sid == split_id {
2361                        let track_hover_style =
2362                            Style::default().bg(self.theme.scrollbar_track_hover_fg);
2363                        let paragraph = Paragraph::new(Span::styled(" ", track_hover_style));
2364                        frame.render_widget(
2365                            paragraph,
2366                            ratatui::layout::Rect::new(
2367                                scrollbar_rect.x,
2368                                scrollbar_rect.y + hovered_row,
2369                                1,
2370                                1,
2371                            ),
2372                        );
2373                    }
2374                }
2375            }
2376            Some(HoverTarget::FileExplorerBorder) => {
2377                // Highlight the file explorer border for resize
2378                if let Some(explorer_area) = self.cached_layout.file_explorer_area {
2379                    let hover_style = Style::default().fg(self.theme.split_separator_hover_fg);
2380                    let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
2381                    for row_offset in 0..explorer_area.height {
2382                        let paragraph = Paragraph::new(Span::styled("│", hover_style));
2383                        frame.render_widget(
2384                            paragraph,
2385                            ratatui::layout::Rect::new(
2386                                border_x,
2387                                explorer_area.y + row_offset,
2388                                1,
2389                                1,
2390                            ),
2391                        );
2392                    }
2393                }
2394            }
2395            // Menu hover is handled by MenuRenderer
2396            _ => {}
2397        }
2398    }
2399
2400    /// Render the tab context menu
2401    fn render_tab_context_menu(&self, frame: &mut Frame, menu: &TabContextMenu) {
2402        use ratatui::style::Style;
2403        use ratatui::text::{Line, Span};
2404        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2405
2406        let items = super::types::TabContextMenuItem::all();
2407        let menu_width = 22u16; // "Close to the Right" + padding
2408        let menu_height = items.len() as u16 + 2; // items + borders
2409
2410        // Adjust position to stay within screen bounds
2411        let screen_width = frame.area().width;
2412        let screen_height = frame.area().height;
2413
2414        let menu_x = if menu.position.0 + menu_width > screen_width {
2415            screen_width.saturating_sub(menu_width)
2416        } else {
2417            menu.position.0
2418        };
2419
2420        let menu_y = if menu.position.1 + menu_height > screen_height {
2421            screen_height.saturating_sub(menu_height)
2422        } else {
2423            menu.position.1
2424        };
2425
2426        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
2427
2428        // Clear the area first
2429        frame.render_widget(Clear, area);
2430
2431        // Build the menu lines
2432        let mut lines = Vec::new();
2433        for (idx, item) in items.iter().enumerate() {
2434            let is_highlighted = idx == menu.highlighted;
2435
2436            let style = if is_highlighted {
2437                Style::default()
2438                    .fg(self.theme.menu_highlight_fg)
2439                    .bg(self.theme.menu_highlight_bg)
2440            } else {
2441                Style::default()
2442                    .fg(self.theme.menu_dropdown_fg)
2443                    .bg(self.theme.menu_dropdown_bg)
2444            };
2445
2446            // Pad the label to fill the menu width
2447            let label = item.label();
2448            let content_width = (menu_width as usize).saturating_sub(2); // -2 for borders
2449            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
2450
2451            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
2452        }
2453
2454        let block = Block::default()
2455            .borders(Borders::ALL)
2456            .border_style(Style::default().fg(self.theme.menu_border_fg))
2457            .style(Style::default().bg(self.theme.menu_dropdown_bg));
2458
2459        let paragraph = Paragraph::new(lines).block(block);
2460        frame.render_widget(paragraph, area);
2461    }
2462
2463    /// Render the file explorer context menu
2464    fn render_file_explorer_context_menu(
2465        &self,
2466        frame: &mut Frame,
2467        menu: &super::types::FileExplorerContextMenu,
2468    ) {
2469        use ratatui::style::Style;
2470        use ratatui::text::{Line, Span};
2471        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2472
2473        let items = menu.items();
2474        let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
2475        let menu_height = menu.height();
2476        let (menu_x, menu_y) = menu.clamped_position(frame.area().width, frame.area().height);
2477
2478        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
2479
2480        frame.render_widget(Clear, area);
2481
2482        let mut lines = Vec::new();
2483        for (idx, item) in items.iter().enumerate() {
2484            let is_highlighted = idx == menu.highlighted;
2485
2486            let style = if is_highlighted {
2487                Style::default()
2488                    .fg(self.theme.menu_highlight_fg)
2489                    .bg(self.theme.menu_highlight_bg)
2490            } else {
2491                Style::default()
2492                    .fg(self.theme.menu_dropdown_fg)
2493                    .bg(self.theme.menu_dropdown_bg)
2494            };
2495
2496            let label = item.label();
2497            let content_width = (menu_width as usize).saturating_sub(2);
2498            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
2499
2500            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
2501        }
2502
2503        let block = Block::default()
2504            .borders(Borders::ALL)
2505            .border_style(Style::default().fg(self.theme.menu_border_fg))
2506            .style(Style::default().bg(self.theme.menu_dropdown_bg));
2507
2508        let paragraph = Paragraph::new(lines).block(block);
2509        frame.render_widget(paragraph, area);
2510    }
2511
2512    /// Render the tab drag drop zone overlay
2513    fn render_tab_drop_zone(&self, frame: &mut Frame, drag_state: &super::types::TabDragState) {
2514        use ratatui::style::Modifier;
2515
2516        let Some(ref drop_zone) = drag_state.drop_zone else {
2517            return;
2518        };
2519
2520        let split_id = drop_zone.split_id();
2521
2522        // Find the content area for the target split
2523        let split_area = self
2524            .cached_layout
2525            .split_areas
2526            .iter()
2527            .find(|(sid, _, _, _, _, _)| *sid == split_id)
2528            .map(|(_, _, content_rect, _, _, _)| *content_rect);
2529
2530        let Some(content_rect) = split_area else {
2531            return;
2532        };
2533
2534        // Determine the highlight area based on drop zone type
2535        use super::types::TabDropZone;
2536
2537        let highlight_area = match drop_zone {
2538            TabDropZone::TabBar(_, _) | TabDropZone::SplitCenter(_) => {
2539                // For tab bar and center drops, highlight the entire split area
2540                // This indicates the tab will be added to this split's tab bar
2541                content_rect
2542            }
2543            TabDropZone::SplitLeft(_) => {
2544                // Left 50% of the split (matches the actual split size created)
2545                let width = (content_rect.width / 2).max(3);
2546                ratatui::layout::Rect::new(
2547                    content_rect.x,
2548                    content_rect.y,
2549                    width,
2550                    content_rect.height,
2551                )
2552            }
2553            TabDropZone::SplitRight(_) => {
2554                // Right 50% of the split (matches the actual split size created)
2555                let width = (content_rect.width / 2).max(3);
2556                let x = content_rect.x + content_rect.width - width;
2557                ratatui::layout::Rect::new(x, content_rect.y, width, content_rect.height)
2558            }
2559            TabDropZone::SplitTop(_) => {
2560                // Top 50% of the split (matches the actual split size created)
2561                let height = (content_rect.height / 2).max(2);
2562                ratatui::layout::Rect::new(
2563                    content_rect.x,
2564                    content_rect.y,
2565                    content_rect.width,
2566                    height,
2567                )
2568            }
2569            TabDropZone::SplitBottom(_) => {
2570                // Bottom 50% of the split (matches the actual split size created)
2571                let height = (content_rect.height / 2).max(2);
2572                let y = content_rect.y + content_rect.height - height;
2573                ratatui::layout::Rect::new(content_rect.x, y, content_rect.width, height)
2574            }
2575        };
2576
2577        // Draw the overlay with the drop zone color
2578        // We apply a semi-transparent effect by modifying existing cells
2579        let buf = frame.buffer_mut();
2580        let drop_zone_bg = self.theme.tab_drop_zone_bg;
2581        let drop_zone_border = self.theme.tab_drop_zone_border;
2582
2583        // Fill the highlight area with a semi-transparent overlay
2584        for y in highlight_area.y..highlight_area.y + highlight_area.height {
2585            for x in highlight_area.x..highlight_area.x + highlight_area.width {
2586                if let Some(cell) = buf.cell_mut((x, y)) {
2587                    // Blend the drop zone color with the existing background
2588                    // For a simple effect, we just set the background
2589                    cell.set_bg(drop_zone_bg);
2590
2591                    // Draw border on edges
2592                    let is_border = x == highlight_area.x
2593                        || x == highlight_area.x + highlight_area.width - 1
2594                        || y == highlight_area.y
2595                        || y == highlight_area.y + highlight_area.height - 1;
2596
2597                    if is_border {
2598                        cell.set_fg(drop_zone_border);
2599                        cell.set_style(cell.style().add_modifier(Modifier::BOLD));
2600                    }
2601                }
2602            }
2603        }
2604
2605        // Draw a border indicator based on the zone type
2606        match drop_zone {
2607            TabDropZone::SplitLeft(_) => {
2608                // Draw vertical indicator on left edge
2609                for y in highlight_area.y..highlight_area.y + highlight_area.height {
2610                    if let Some(cell) = buf.cell_mut((highlight_area.x, y)) {
2611                        cell.set_symbol("▌");
2612                        cell.set_fg(drop_zone_border);
2613                    }
2614                }
2615            }
2616            TabDropZone::SplitRight(_) => {
2617                // Draw vertical indicator on right edge
2618                let x = highlight_area.x + highlight_area.width - 1;
2619                for y in highlight_area.y..highlight_area.y + highlight_area.height {
2620                    if let Some(cell) = buf.cell_mut((x, y)) {
2621                        cell.set_symbol("▐");
2622                        cell.set_fg(drop_zone_border);
2623                    }
2624                }
2625            }
2626            TabDropZone::SplitTop(_) => {
2627                // Draw horizontal indicator on top edge
2628                for x in highlight_area.x..highlight_area.x + highlight_area.width {
2629                    if let Some(cell) = buf.cell_mut((x, highlight_area.y)) {
2630                        cell.set_symbol("▀");
2631                        cell.set_fg(drop_zone_border);
2632                    }
2633                }
2634            }
2635            TabDropZone::SplitBottom(_) => {
2636                // Draw horizontal indicator on bottom edge
2637                let y = highlight_area.y + highlight_area.height - 1;
2638                for x in highlight_area.x..highlight_area.x + highlight_area.width {
2639                    if let Some(cell) = buf.cell_mut((x, y)) {
2640                        cell.set_symbol("▄");
2641                        cell.set_fg(drop_zone_border);
2642                    }
2643                }
2644            }
2645            TabDropZone::SplitCenter(_) | TabDropZone::TabBar(_, _) => {
2646                // For center and tab bar, the filled background is sufficient
2647            }
2648        }
2649    }
2650
2651    /// Recompute the view_line_mappings layout without drawing.
2652    /// Used during macro replay so that visual-line movements (MoveLineEnd,
2653    /// MoveUp, MoveDown on wrapped lines) see correct, up-to-date layout
2654    /// information between each replayed action.
2655    pub fn recompute_layout(&mut self, width: u16, height: u16) {
2656        let size = ratatui::layout::Rect::new(0, 0, width, height);
2657
2658        // Replicate the pre-render sync steps from render()
2659        let active_split = self.split_manager.active_split();
2660        self.pre_sync_ensure_visible(active_split);
2661        self.sync_scroll_groups();
2662
2663        // Replicate the layout computation that produces editor_content_area.
2664        // Same constraints as render(): [menu_bar, main_content, status_bar, search_options, prompt_line]
2665        let constraints = vec![
2666            Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }),
2667            Constraint::Min(0),
2668            Constraint::Length(if self.status_bar_visible { 1 } else { 0 }), // status bar
2669            Constraint::Length(0), // search options (doesn't matter for layout)
2670            Constraint::Length(if self.prompt_line_visible { 1 } else { 0 }), // prompt line
2671        ];
2672        let main_chunks = Layout::default()
2673            .direction(Direction::Vertical)
2674            .constraints(constraints)
2675            .split(size);
2676        let main_content_area = main_chunks[1];
2677
2678        // Compute editor_content_area (with file explorer split if visible)
2679        let file_explorer_should_show = self.file_explorer_visible
2680            && (self.file_explorer.is_some() || self.file_explorer_sync_in_progress);
2681        let editor_content_area = if file_explorer_should_show {
2682            let explorer_cols = self.file_explorer_width.to_cols(main_content_area.width);
2683            let horizontal_chunks = Layout::default()
2684                .direction(Direction::Horizontal)
2685                .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
2686                .split(main_content_area);
2687            horizontal_chunks[1]
2688        } else {
2689            main_content_area
2690        };
2691
2692        // Compute layout for all visible splits and update cached view_line_mappings
2693        let view_line_mappings = SplitRenderer::compute_content_layout(
2694            editor_content_area,
2695            &self.split_manager,
2696            &mut self.buffers,
2697            &mut self.split_view_states,
2698            &self.theme,
2699            false, // lsp_waiting — not relevant for layout
2700            self.config.editor.estimated_line_length,
2701            self.config.editor.highlight_context_bytes,
2702            self.config.editor.relative_line_numbers,
2703            self.config.editor.use_terminal_bg,
2704            self.session_mode || !self.software_cursor_only,
2705            self.software_cursor_only,
2706            self.tab_bar_visible,
2707            self.config.editor.show_vertical_scrollbar,
2708            self.config.editor.show_horizontal_scrollbar,
2709            self.config.editor.diagnostics_inline_text,
2710            self.config.editor.show_tilde,
2711        );
2712
2713        self.cached_layout.view_line_mappings = view_line_mappings;
2714    }
2715
2716    /// Clear the search history
2717    /// Used primarily for testing to ensure test isolation
2718    pub fn clear_search_history(&mut self) {
2719        if let Some(history) = self.prompt_histories.get_mut("search") {
2720            history.clear();
2721        }
2722    }
2723
2724    /// Emit an OSC 2 escape sequence to set the host terminal's window/tab
2725    /// title based on the active buffer's display name and the project name
2726    /// (the working directory's last path component). Deduplicated against
2727    /// the last title we wrote so we don't spam stdout every frame.
2728    ///
2729    /// Gated by `editor.set_window_title` (default on). Terminals that
2730    /// don't implement OSC 2 silently drop the sequence.
2731    fn update_terminal_title(&mut self, display_name: &str) {
2732        if !self.config.editor.set_window_title {
2733            return;
2734        }
2735        let project_name = self.working_dir.file_name().and_then(|s| s.to_str());
2736        let new_title =
2737            crate::services::terminal_title::build_window_title(display_name, project_name);
2738        if self.last_window_title.as_deref() == Some(new_title.as_str()) {
2739            return;
2740        }
2741        crate::services::terminal_title::write_terminal_title(&new_title);
2742        self.last_window_title = Some(new_title);
2743    }
2744
2745    /// Save all prompt histories to disk
2746    /// Called on shutdown to persist history across sessions
2747    pub fn save_histories(&self) {
2748        // Ensure data directory exists
2749        if let Err(e) = self
2750            .authority
2751            .filesystem
2752            .create_dir_all(&self.dir_context.data_dir)
2753        {
2754            tracing::warn!("Failed to create data directory: {}", e);
2755            return;
2756        }
2757
2758        // Save all prompt histories
2759        for (key, history) in &self.prompt_histories {
2760            let path = self.dir_context.prompt_history_path(key);
2761            if let Err(e) = history.save_to_file(&path) {
2762                tracing::warn!("Failed to save {} history: {}", key, e);
2763            } else {
2764                tracing::debug!("Saved {} history to {:?}", key, path);
2765            }
2766        }
2767    }
2768
2769    /// Resolve a plugin-supplied [`OverlayOptions`] to a ratatui
2770    /// [`Style`] against the active theme. RGB colours pass through;
2771    /// theme keys (e.g. `"ui.help_key_fg"`) are looked up via
2772    /// `theme.resolve_theme_key`. Mirrors the resolution
2773    /// `OverlayFace::from_options` + char_style.rs do for buffer
2774    /// overlays — pulled here so the prompt-frame renderer can build
2775    /// styled spans inline.
2776    fn resolve_overlay_style(
2777        opts: &fresh_core::api::OverlayOptions,
2778        theme: &crate::view::theme::Theme,
2779    ) -> ratatui::style::Style {
2780        use crate::view::theme::named_color_from_str;
2781        use fresh_core::api::OverlayColorSpec;
2782        use ratatui::style::{Color, Modifier, Style};
2783
2784        let resolve = |spec: &OverlayColorSpec| -> Option<Color> {
2785            match spec {
2786                OverlayColorSpec::Rgb(r, g, b) => Some(Color::Rgb(*r, *g, *b)),
2787                OverlayColorSpec::ThemeKey(k) => {
2788                    named_color_from_str(k).or_else(|| theme.resolve_theme_key(k))
2789                }
2790            }
2791        };
2792
2793        let mut style = Style::default();
2794        if let Some(ref fg) = opts.fg {
2795            if let Some(c) = resolve(fg) {
2796                style = style.fg(c);
2797            }
2798        }
2799        if let Some(ref bg) = opts.bg {
2800            if let Some(c) = resolve(bg) {
2801                style = style.bg(c);
2802            }
2803        }
2804        let mut m = Modifier::empty();
2805        if opts.bold {
2806            m |= Modifier::BOLD;
2807        }
2808        if opts.italic {
2809            m |= Modifier::ITALIC;
2810        }
2811        if opts.underline {
2812            m |= Modifier::UNDERLINED;
2813        }
2814        if opts.strikethrough {
2815            m |= Modifier::CROSSED_OUT;
2816        }
2817        if !m.is_empty() {
2818            style = style.add_modifier(m);
2819        }
2820        style
2821    }
2822}