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        );
1606        if self.cached_layout.suggestions_area.is_some() {
1607            self.cached_layout.suggestions_outer_area = Some(suggestions_area);
1608        }
1609
1610        if is_quick_open {
1611            let hints_area = ratatui::layout::Rect {
1612                x: 0,
1613                y: prompt_area.y.saturating_sub(hints_height),
1614                width,
1615                height: hints_height,
1616            };
1617            frame.render_widget(ratatui::widgets::Clear, hints_area);
1618            Self::render_quick_open_hints(frame, hints_area, &self.theme);
1619        }
1620    }
1621
1622    /// Resolve the overlay's currently-selected match into a real
1623    /// `Buffer` parked in a phantom `LeafId`, so the preview pane can
1624    /// reuse the regular per-leaf renderer (with syntax highlighting,
1625    /// gutter, scrollbars, folding). No-op when the prompt has no
1626    /// selection or its label is not a `path:line[:col]` triple.
1627    fn prepare_overlay_preview(&mut self) {
1628        use crate::input::quick_open::parse_path_line_col;
1629
1630        let (path_str, line, col) = {
1631            let Some(prompt) = self.prompt.as_ref() else {
1632                return;
1633            };
1634            let Some(idx) = prompt.selected_suggestion else {
1635                return;
1636            };
1637            let Some(s) = prompt.suggestions.get(idx) else {
1638                return;
1639            };
1640            // Suggestions emitted by the Finder library use `value` as
1641            // an opaque index; the parseable label lives in `text`.
1642            // Resume-replay is the inverse: `value` carries the full
1643            // path:line:col triple.
1644            let from_text = parse_path_line_col(&s.text);
1645            if !from_text.0.is_empty() && from_text.1.is_some() {
1646                from_text
1647            } else if let Some(v) = s.value.as_deref() {
1648                parse_path_line_col(v)
1649            } else {
1650                from_text
1651            }
1652        };
1653        if path_str.is_empty() {
1654            return;
1655        }
1656        let line = line.unwrap_or(1).saturating_sub(1);
1657        let col = col.unwrap_or(1).saturating_sub(1);
1658
1659        // Resolve relative to the working directory.
1660        let path_buf = std::path::PathBuf::from(&path_str);
1661        let abs_path = if path_buf.is_absolute() {
1662            path_buf
1663        } else {
1664            self.working_dir.join(&path_buf)
1665        };
1666        // Canonicalize for buffer-dedup parity with open_file_no_focus.
1667        let abs_path = self
1668            .authority
1669            .filesystem
1670            .canonicalize(&abs_path)
1671            .unwrap_or(abs_path);
1672
1673        // If the standalone state already targets this path, just
1674        // re-seed the cursor and skip the file-load roundtrip.
1675        let already_target = self.overlay_preview_state.as_ref().is_some_and(|st| {
1676            self.buffers
1677                .get(&st.buffer_id)
1678                .and_then(|s| s.buffer.file_path())
1679                .is_some_and(|p| p == abs_path.as_path())
1680        });
1681
1682        let buffer_id = if already_target {
1683            self.overlay_preview_state.as_ref().unwrap().buffer_id
1684        } else {
1685            // Snapshot whether this path was already known so we can
1686            // tell "I just loaded it for preview" from "the user had
1687            // it open" — only the former gets cleaned up on close.
1688            let was_open = self
1689                .buffers
1690                .iter()
1691                .any(|(_, s)| s.buffer.file_path() == Some(abs_path.as_path()));
1692            // Capture the active split so we can undo the side
1693            // effects of `open_file_no_focus` (it adds the buffer to
1694            // the active split's tabs and may switch its active
1695            // buffer to the loaded file).
1696            let source_split = self.split_manager.active_split();
1697            // `open_file_for_preview` always allocates a fresh buffer
1698            // — never repurposes the "no name" empty buffer the user
1699            // is currently looking at — so the background view stays
1700            // intact while we cycle through preview results.
1701            let buffer_id = match self.open_file_for_preview(abs_path.as_path()) {
1702                Ok(id) => id,
1703                Err(_e) => return,
1704            };
1705            if !was_open {
1706                if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
1707                    meta.hidden_from_tabs = true;
1708                }
1709                // Drop the buffer from every split's `open_buffers`
1710                // list so it doesn't surface as a tab anywhere. The
1711                // phantom buffer is rendered exclusively via the
1712                // overlay's standalone view-state — it doesn't need
1713                // to be in `open_buffers`.
1714                let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
1715                for leaf_id in leaf_ids {
1716                    if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
1717                        view_state.remove_buffer(buffer_id);
1718                    }
1719                }
1720                // open_file_no_focus may have switched the active
1721                // buffer of the source split. Restore it.
1722                if let Some(source_state) = self.split_view_states.get_mut(&source_split) {
1723                    if source_state.active_buffer == buffer_id {
1724                        let preview_loaded: std::collections::HashSet<BufferId> = self
1725                            .overlay_preview_state
1726                            .as_ref()
1727                            .map(|st| st.loaded_buffers.clone())
1728                            .unwrap_or_default();
1729                        let fallback = source_state
1730                            .open_buffers
1731                            .iter()
1732                            .find_map(|t| t.as_buffer())
1733                            .or_else(|| {
1734                                self.buffers
1735                                    .keys()
1736                                    .copied()
1737                                    .find(|b| *b != buffer_id && !preview_loaded.contains(b))
1738                            });
1739                        if let Some(fb) = fallback {
1740                            source_state.switch_buffer(fb);
1741                            self.split_manager.set_split_buffer(source_split, fb);
1742                        }
1743                    }
1744                }
1745                self.split_manager.set_active_split(source_split);
1746            }
1747            buffer_id
1748        };
1749
1750        // Build (or update) the standalone preview state. Held off
1751        // `split_view_states` so cross-cutting iteration never touches
1752        // it.
1753        let need_init = self.overlay_preview_state.is_none();
1754        if need_init {
1755            let mut view_state = crate::view::split::SplitViewState::with_buffer(
1756                self.terminal_width,
1757                self.terminal_height,
1758                buffer_id,
1759            );
1760            view_state.apply_config_defaults(
1761                self.config.editor.line_numbers,
1762                self.config.editor.highlight_current_line,
1763                self.resolve_line_wrap_for_buffer(buffer_id),
1764                self.config.editor.wrap_indent,
1765                self.resolve_wrap_column_for_buffer(buffer_id),
1766                self.config.editor.rulers.clone(),
1767            );
1768            let mut loaded_buffers = std::collections::HashSet::new();
1769            // Whether this *first* preview buffer was newly loaded.
1770            // The pre-existing case skips the `was_open` branch so
1771            // we re-derive it from buffer_metadata: a buffer with
1772            // hidden_from_tabs=true that we just touched is one we
1773            // owned. Simpler: track via the existing-target check:
1774            // if `already_target` was false above, the buffer was
1775            // either pre-open (we left meta alone) or freshly
1776            // loaded (we set hidden_from_tabs=true). Re-check.
1777            if let Some(meta) = self.buffer_metadata.get(&buffer_id) {
1778                if meta.hidden_from_tabs {
1779                    loaded_buffers.insert(buffer_id);
1780                }
1781            }
1782            self.overlay_preview_state = Some(crate::app::types::OverlayPreviewState {
1783                buffer_id,
1784                view_state,
1785                loaded_buffers,
1786            });
1787        } else if let Some(state) = self.overlay_preview_state.as_mut() {
1788            if state.buffer_id != buffer_id {
1789                state.view_state.switch_buffer(buffer_id);
1790                state.buffer_id = buffer_id;
1791                if let Some(meta) = self.buffer_metadata.get(&buffer_id) {
1792                    if meta.hidden_from_tabs {
1793                        state.loaded_buffers.insert(buffer_id);
1794                    }
1795                }
1796            }
1797        }
1798
1799        // Set the cursor to the match position and centre the line.
1800        let byte_offset = self
1801            .buffers
1802            .get(&buffer_id)
1803            .map(|s| {
1804                s.buffer
1805                    .position_to_offset(crate::model::piece_tree::Position { line, column: col })
1806            })
1807            .unwrap_or(0);
1808        let line_start = self
1809            .buffers
1810            .get(&buffer_id)
1811            .and_then(|s| s.buffer.line_start_offset(line))
1812            .unwrap_or(byte_offset);
1813        if let Some(state) = self.overlay_preview_state.as_mut() {
1814            state.view_state.cursors.primary_mut().position = byte_offset;
1815            let h = state.view_state.viewport.height.max(1) as usize;
1816            let half = h / 2;
1817            let target_top_line = line.saturating_sub(half);
1818            let top_byte = self
1819                .buffers
1820                .get(&buffer_id)
1821                .and_then(|s| s.buffer.line_start_offset(target_top_line))
1822                .unwrap_or(line_start);
1823            state.view_state.viewport.top_byte = top_byte;
1824        }
1825    }
1826
1827    /// Render the active prompt as a centred floating overlay
1828    /// (issue #1796). Layout, top-down inside the overlay frame:
1829    ///
1830    /// ```text
1831    /// ┌─ Live Grep ──────────────────────────────────[Esc to close]┐
1832    /// │ Search: split_active|                           12 / 142    │  ← input row
1833    /// │ ─────────────────────────────────────────────────────────── │
1834    /// │  src/view/split.rs:1117  pub fn split_active(    │ preview │  ← results
1835    /// │  src/view/split.rs:1123  self.split_active_pos…  │  pane   │     (+ optional
1836    /// │ …                                                │         │      preview)
1837    /// └────────────────────────────────────────────────────────────┘
1838    /// ```
1839    ///
1840    /// The overlay does *not* mutate the split tree; it is a pure
1841    /// `ratatui` overdraw, so dismissing leaves the user's underlying
1842    /// layout exactly as it was (the issue-#1796 acceptance test).
1843    fn render_overlay_prompt(&mut self, frame: &mut Frame, area: ratatui::layout::Rect) {
1844        use crate::view::popup::PopupPosition;
1845        use ratatui::layout::Rect;
1846        use ratatui::style::{Modifier, Style};
1847        use ratatui::text::{Line, Span};
1848        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1849
1850        // Compute the overlay rect via the same percentage logic the
1851        // popup engine uses. 80% × 80% of the terminal, centred.
1852        let overlay_pos = PopupPosition::CenteredOverlay {
1853            width_pct: 80,
1854            height_pct: 80,
1855        };
1856        let overlay_rect = match overlay_pos {
1857            PopupPosition::CenteredOverlay {
1858                width_pct,
1859                height_pct,
1860            } => {
1861                let w_pct = width_pct.clamp(1, 100) as u32;
1862                let h_pct = height_pct.clamp(1, 100) as u32;
1863                let w = ((area.width as u32 * w_pct) / 100) as u16;
1864                let h = ((area.height as u32 * h_pct) / 100) as u16;
1865                let w = w.max(20).min(area.width);
1866                let h = h.max(8).min(area.height);
1867                Rect {
1868                    x: (area.width.saturating_sub(w)) / 2,
1869                    y: (area.height.saturating_sub(h)) / 2,
1870                    width: w,
1871                    height: h,
1872                }
1873            }
1874            _ => unreachable!(),
1875        };
1876
1877        // Snapshot view-relevant state before any mutable borrows.
1878        let theme = self.theme.clone();
1879        // The suggestion list inside the overlay can be ~30 rows
1880        // tall on a typical terminal. Pass the *actual* visible
1881        // count to `ensure_selected_visible_within` so the scroll
1882        // offset only advances when the selection genuinely passes
1883        // the bottom of the visible window — not when it crosses
1884        // the bottom-popup default cap of `MAX_VISIBLE_SUGGESTIONS`
1885        // (= 10), which would scroll prematurely.
1886        //
1887        // Geometry: overlay frame border (2) + input row (1) +
1888        // separator (1) + suggestions-popup own border (2) = 6
1889        // rows of chrome above the suggestion items themselves.
1890        // The popup's inner content height is `overlay.height - 6`.
1891        let suggestions_visible_rows = (overlay_rect.height as usize).saturating_sub(6);
1892        if let Some(prompt) = self.prompt.as_mut() {
1893            prompt.ensure_selected_visible_within(suggestions_visible_rows);
1894        }
1895        let Some(prompt) = self.prompt.as_ref() else {
1896            return;
1897        };
1898        let prompt = prompt.clone();
1899
1900        // Clear and frame. Plugin-owned prompts can publish their
1901        // own title via `editor.setPromptTitle(...)`; falls back to
1902        // " Live Grep " plus shortcut hints when unset (so a
1903        // Resume-replay prompt and freshly-opened plugin prompt look
1904        // similar even though they take different code paths).
1905        frame.render_widget(Clear, overlay_rect);
1906        let default_title_owned: String = {
1907            use crate::input::keybindings::KeyContext;
1908            let keybindings = self.keybindings.read().unwrap();
1909            let mut hints: Vec<String> = Vec::new();
1910            if let Some(k) = keybindings
1911                .find_keybinding_for_action("cycle_live_grep_provider", KeyContext::Prompt)
1912            {
1913                hints.push(format!("{k} cycle"));
1914            }
1915            if let Some(k) = keybindings
1916                .find_keybinding_for_action("live_grep_export_quickfix", KeyContext::Prompt)
1917            {
1918                hints.push(format!("{k} → Quickfix"));
1919            }
1920            if let Some(k) =
1921                keybindings.find_keybinding_for_action("resume_live_grep", KeyContext::Normal)
1922            {
1923                hints.push(format!("{k} resume"));
1924            }
1925            if hints.is_empty() {
1926                " Live Grep ".to_string()
1927            } else {
1928                format!(" Live Grep · {} ", hints.join(" · "))
1929            }
1930        };
1931        let title_owned: String;
1932        let title: &str = match prompt.title.as_deref() {
1933            Some(t) if !t.is_empty() => {
1934                // Pad with single spaces so it sits flush with the
1935                // frame's corners regardless of length.
1936                title_owned = format!(" {} ", t.trim());
1937                &title_owned
1938            }
1939            _ => &default_title_owned,
1940        };
1941        let block = Block::default()
1942            .borders(Borders::ALL)
1943            .border_style(Style::default().fg(theme.popup_border_fg))
1944            .style(Style::default().bg(theme.suggestion_bg))
1945            .title(Span::styled(
1946                title,
1947                Style::default()
1948                    .fg(theme.prompt_fg)
1949                    .add_modifier(Modifier::BOLD),
1950            ));
1951        let inner = block.inner(overlay_rect);
1952        frame.render_widget(block, overlay_rect);
1953
1954        if inner.height == 0 || inner.width == 0 {
1955            return;
1956        }
1957
1958        // Decide whether to split the inner area into results | preview.
1959        // Below ~120 cols, stack results-only (preview hidden — see
1960        // design doc §5 "preview pane size when terminal is narrow").
1961        let preview_min_cols: u16 = 120;
1962        let show_preview = overlay_rect.width >= preview_min_cols;
1963        let (results_area, preview_area) = if show_preview {
1964            let results_w = inner.width / 2;
1965            (
1966                Rect {
1967                    x: inner.x,
1968                    y: inner.y,
1969                    width: results_w,
1970                    height: inner.height,
1971                },
1972                Some(Rect {
1973                    x: inner.x + results_w,
1974                    y: inner.y,
1975                    width: inner.width - results_w,
1976                    height: inner.height,
1977                }),
1978            )
1979        } else {
1980            (inner, None)
1981        };
1982
1983        // Top row of `results_area` is the prompt input.
1984        let input_row = Rect {
1985            x: results_area.x,
1986            y: results_area.y,
1987            width: results_area.width,
1988            height: 1,
1989        };
1990        let input_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
1991        let count_str = if prompt.suggestions.is_empty() {
1992            String::new()
1993        } else {
1994            format!(
1995                "  {} / {}",
1996                prompt.selected_suggestion.map(|i| i + 1).unwrap_or(0),
1997                prompt.suggestions.len()
1998            )
1999        };
2000        let visible_input_width =
2001            results_area.width.saturating_sub(count_str.len() as u16) as usize;
2002        let truncated_input: String = prompt
2003            .input
2004            .chars()
2005            .take(visible_input_width.saturating_sub(prompt.message.len()))
2006            .collect();
2007        let line = Line::from(vec![
2008            Span::styled(prompt.message.clone(), input_style),
2009            Span::styled(truncated_input, input_style),
2010            Span::styled(
2011                count_str,
2012                Style::default()
2013                    .fg(theme.popup_border_fg)
2014                    .bg(theme.suggestion_bg),
2015            ),
2016        ]);
2017        frame.render_widget(Paragraph::new(line).style(input_style), input_row);
2018
2019        // Cursor position on the input row.
2020        use crate::primitives::display_width::str_width;
2021        let cursor_x = (str_width(&prompt.message)
2022            + str_width(&prompt.input[..prompt.cursor_pos.min(prompt.input.len())]))
2023            as u16;
2024        if cursor_x < input_row.width {
2025            frame.set_cursor_position((input_row.x + cursor_x, input_row.y));
2026        }
2027
2028        // Separator row.
2029        if results_area.height >= 2 {
2030            let sep = Rect {
2031                x: results_area.x,
2032                y: results_area.y + 1,
2033                width: results_area.width,
2034                height: 1,
2035            };
2036            let sep_style = Style::default()
2037                .fg(theme.popup_border_fg)
2038                .bg(theme.suggestion_bg);
2039            let sep_text = "─".repeat(results_area.width as usize);
2040            frame.render_widget(Paragraph::new(sep_text).style(sep_style), sep);
2041        }
2042
2043        // Suggestions list fills the rest of `results_area`. Carve
2044        // off the rightmost 1-column lane for a scrollbar so the
2045        // user can see how far through the result set the selection
2046        // is — important when the visible area only fits ~30 of
2047        // 100+ matches. Only carve when the result set actually
2048        // exceeds the visible rows; otherwise the scrollbar is
2049        // visual noise.
2050        if results_area.height > 2 {
2051            let inner_rows = (results_area.height - 2).saturating_sub(2) as usize; // popup own border
2052            let needs_scrollbar = prompt.suggestions.len() > inner_rows.max(1);
2053            let scrollbar_w: u16 = if needs_scrollbar { 1 } else { 0 };
2054            let list_area = Rect {
2055                x: results_area.x,
2056                y: results_area.y + 2,
2057                width: results_area.width.saturating_sub(scrollbar_w),
2058                height: results_area.height - 2,
2059            };
2060            self.cached_layout.suggestions_area = SuggestionsRenderer::render_with_hover(
2061                frame,
2062                list_area,
2063                &prompt,
2064                &theme,
2065                self.mouse_state.hover_target.as_ref(),
2066            );
2067            if self.cached_layout.suggestions_area.is_some() {
2068                self.cached_layout.suggestions_outer_area = Some(list_area);
2069            }
2070            // Render the scrollbar in the carved lane. Reuses the
2071            // shared `view::ui::scrollbar` widget so thumb sizing
2072            // and theme colours match scrollbars elsewhere in the
2073            // editor (split rendering, file explorer, …).
2074            if needs_scrollbar {
2075                use crate::view::ui::scrollbar::{
2076                    render_scrollbar, ScrollbarColors, ScrollbarState,
2077                };
2078                // Scrollbar rect aligns with the suggestions popup's
2079                // *inner* area (skipping the popup's own top/bottom
2080                // border) so the thumb tracks the visible items
2081                // exactly.
2082                let scrollbar_rect = Rect {
2083                    x: results_area.x + results_area.width - 1,
2084                    y: list_area.y + 1,
2085                    width: 1,
2086                    height: list_area.height.saturating_sub(2),
2087                };
2088                let state = ScrollbarState::new(
2089                    prompt.suggestions.len(),
2090                    inner_rows.max(1),
2091                    prompt.scroll_offset,
2092                );
2093                render_scrollbar(
2094                    frame,
2095                    scrollbar_rect,
2096                    &state,
2097                    &ScrollbarColors::from_theme(&theme),
2098                );
2099                // Cache the rect for mouse hit testing in
2100                // `mouse_input.rs::handle_click_prompt_scrollbar`.
2101                self.cached_layout.suggestions_scrollbar_rect = Some(scrollbar_rect);
2102            } else {
2103                self.cached_layout.suggestions_scrollbar_rect = None;
2104            }
2105        } else {
2106            self.cached_layout.suggestions_scrollbar_rect = None;
2107        }
2108
2109        // Right-half preview pane: a real Buffer rendered via the
2110        // same per-leaf pipeline regular splits use. Buffer + cursor
2111        // are already seeded by `prepare_overlay_preview` (called
2112        // earlier in the render flow). Borrows are split here so we
2113        // can hand out independent `&mut` references to the
2114        // renderer's internals without going back through `&mut self`.
2115        if let Some(preview_rect) = preview_area {
2116            // Frame the preview area first (vertical separator) so
2117            // the renderer fills the inner rect.
2118            use ratatui::widgets::{Block, Borders, Clear};
2119            frame.render_widget(Clear, preview_rect);
2120            let block = Block::default()
2121                .borders(Borders::LEFT)
2122                .border_style(Style::default().fg(theme.popup_border_fg))
2123                .style(Style::default().bg(theme.suggestion_bg));
2124            let inner = block.inner(preview_rect);
2125            frame.render_widget(block, preview_rect);
2126
2127            if inner.height > 0 && inner.width > 0 {
2128                // Snapshot scalar config values up front so the
2129                // mutable-borrow split below has minimal scope.
2130                // AnsiBackground isn't Clone, so it's taken as a
2131                // borrow; Rust permits disjoint-field splitting
2132                // between `&self.ansi_background` and the `&mut`
2133                // accesses below because they touch distinct fields.
2134                let bg_fade = self.background_fade;
2135                let estimated_line_length = self.config.editor.estimated_line_length;
2136                let highlight_context_bytes = self.config.editor.highlight_context_bytes;
2137                let relative_line_numbers = self.config.editor.relative_line_numbers;
2138                let use_terminal_bg = self.config.editor.use_terminal_bg;
2139                let session_mode = self.session_mode || !self.software_cursor_only;
2140                let software_cursor_only = self.software_cursor_only;
2141                let diagnostics_inline_text = self.config.editor.diagnostics_inline_text;
2142                let show_tilde = false; // preview hides tilde markers
2143                let highlight_current_column = self.config.editor.highlight_current_column;
2144                let screen_width = frame.area().width;
2145
2146                let ansi_ref = self.ansi_background.as_ref();
2147                let buffers = &mut self.buffers;
2148                let event_logs = &mut self.event_logs;
2149                let cell_theme_map = &mut self.cached_layout.cell_theme_map;
2150                let Some(preview_state) = self.overlay_preview_state.as_mut() else {
2151                    return;
2152                };
2153                preview_state
2154                    .view_state
2155                    .viewport
2156                    .resize(inner.width, inner.height);
2157                let buffer_id = preview_state.buffer_id;
2158
2159                if let Some(state) = buffers.get_mut(&buffer_id) {
2160                    // Deref the SplitViewState once to a concrete
2161                    // `&mut BufferViewState` so disjoint field
2162                    // splits (`viewport` + `folds`) are visible
2163                    // to the borrow checker.
2164                    let buf_state = preview_state.view_state.active_state_mut();
2165                    let cursors = buf_state.cursors.clone();
2166                    let view_mode = buf_state.view_mode.clone();
2167                    let compose_width = buf_state.compose_width;
2168                    let compose_column_guides = buf_state.compose_column_guides.clone();
2169                    let view_transform = buf_state.view_transform.clone();
2170                    let rulers = buf_state.rulers.clone();
2171                    let show_line_numbers = buf_state.show_line_numbers;
2172                    let highlight_current_line = buf_state.highlight_current_line;
2173                    let viewport_ref = &mut buf_state.viewport;
2174                    let folds_ref = &mut buf_state.folds;
2175                    let event_log = event_logs.get_mut(&buffer_id);
2176                    let _ = crate::view::ui::SplitRenderer::render_phantom_leaf(
2177                        frame,
2178                        state,
2179                        &cursors,
2180                        viewport_ref,
2181                        folds_ref,
2182                        event_log,
2183                        inner,
2184                        &theme,
2185                        ansi_ref,
2186                        bg_fade,
2187                        view_mode,
2188                        compose_width,
2189                        compose_column_guides,
2190                        view_transform,
2191                        estimated_line_length,
2192                        highlight_context_bytes,
2193                        buffer_id,
2194                        relative_line_numbers,
2195                        use_terminal_bg,
2196                        session_mode,
2197                        software_cursor_only,
2198                        &rulers,
2199                        show_line_numbers,
2200                        highlight_current_line,
2201                        diagnostics_inline_text,
2202                        show_tilde,
2203                        highlight_current_column,
2204                        cell_theme_map,
2205                        screen_width,
2206                    );
2207                }
2208            }
2209        }
2210    }
2211
2212    /// Render hover highlights for interactive elements (separators, scrollbars)
2213    pub(super) fn render_hover_highlights(&self, frame: &mut Frame) {
2214        use ratatui::style::Style;
2215        use ratatui::text::Span;
2216        use ratatui::widgets::Paragraph;
2217
2218        match &self.mouse_state.hover_target {
2219            Some(HoverTarget::SplitSeparator(split_id, direction)) => {
2220                // Highlight the separator with hover color
2221                for (sid, dir, x, y, length) in &self.cached_layout.separator_areas {
2222                    if sid == split_id && dir == direction {
2223                        let hover_style = Style::default().fg(self.theme.split_separator_hover_fg);
2224                        match dir {
2225                            SplitDirection::Horizontal => {
2226                                let line_text = "─".repeat(*length as usize);
2227                                let paragraph =
2228                                    Paragraph::new(Span::styled(line_text, hover_style));
2229                                frame.render_widget(
2230                                    paragraph,
2231                                    ratatui::layout::Rect::new(*x, *y, *length, 1),
2232                                );
2233                            }
2234                            SplitDirection::Vertical => {
2235                                for offset in 0..*length {
2236                                    let paragraph = Paragraph::new(Span::styled("│", hover_style));
2237                                    frame.render_widget(
2238                                        paragraph,
2239                                        ratatui::layout::Rect::new(*x, y + offset, 1, 1),
2240                                    );
2241                                }
2242                            }
2243                        }
2244                    }
2245                }
2246            }
2247            Some(HoverTarget::ScrollbarThumb(split_id)) => {
2248                // Highlight scrollbar thumb
2249                for (sid, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
2250                    &self.cached_layout.split_areas
2251                {
2252                    if sid == split_id {
2253                        let hover_style = Style::default().bg(self.theme.scrollbar_thumb_hover_fg);
2254                        for row_offset in *thumb_start..*thumb_end {
2255                            let paragraph = Paragraph::new(Span::styled(" ", hover_style));
2256                            frame.render_widget(
2257                                paragraph,
2258                                ratatui::layout::Rect::new(
2259                                    scrollbar_rect.x,
2260                                    scrollbar_rect.y + row_offset as u16,
2261                                    1,
2262                                    1,
2263                                ),
2264                            );
2265                        }
2266                    }
2267                }
2268            }
2269            Some(HoverTarget::ScrollbarTrack(split_id, hovered_row)) => {
2270                // Highlight only the hovered cell on the scrollbar track
2271                for (sid, _buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
2272                    &self.cached_layout.split_areas
2273                {
2274                    if sid == split_id {
2275                        let track_hover_style =
2276                            Style::default().bg(self.theme.scrollbar_track_hover_fg);
2277                        let paragraph = Paragraph::new(Span::styled(" ", track_hover_style));
2278                        frame.render_widget(
2279                            paragraph,
2280                            ratatui::layout::Rect::new(
2281                                scrollbar_rect.x,
2282                                scrollbar_rect.y + hovered_row,
2283                                1,
2284                                1,
2285                            ),
2286                        );
2287                    }
2288                }
2289            }
2290            Some(HoverTarget::FileExplorerBorder) => {
2291                // Highlight the file explorer border for resize
2292                if let Some(explorer_area) = self.cached_layout.file_explorer_area {
2293                    let hover_style = Style::default().fg(self.theme.split_separator_hover_fg);
2294                    let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
2295                    for row_offset in 0..explorer_area.height {
2296                        let paragraph = Paragraph::new(Span::styled("│", hover_style));
2297                        frame.render_widget(
2298                            paragraph,
2299                            ratatui::layout::Rect::new(
2300                                border_x,
2301                                explorer_area.y + row_offset,
2302                                1,
2303                                1,
2304                            ),
2305                        );
2306                    }
2307                }
2308            }
2309            // Menu hover is handled by MenuRenderer
2310            _ => {}
2311        }
2312    }
2313
2314    /// Render the tab context menu
2315    fn render_tab_context_menu(&self, frame: &mut Frame, menu: &TabContextMenu) {
2316        use ratatui::style::Style;
2317        use ratatui::text::{Line, Span};
2318        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2319
2320        let items = super::types::TabContextMenuItem::all();
2321        let menu_width = 22u16; // "Close to the Right" + padding
2322        let menu_height = items.len() as u16 + 2; // items + borders
2323
2324        // Adjust position to stay within screen bounds
2325        let screen_width = frame.area().width;
2326        let screen_height = frame.area().height;
2327
2328        let menu_x = if menu.position.0 + menu_width > screen_width {
2329            screen_width.saturating_sub(menu_width)
2330        } else {
2331            menu.position.0
2332        };
2333
2334        let menu_y = if menu.position.1 + menu_height > screen_height {
2335            screen_height.saturating_sub(menu_height)
2336        } else {
2337            menu.position.1
2338        };
2339
2340        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
2341
2342        // Clear the area first
2343        frame.render_widget(Clear, area);
2344
2345        // Build the menu lines
2346        let mut lines = Vec::new();
2347        for (idx, item) in items.iter().enumerate() {
2348            let is_highlighted = idx == menu.highlighted;
2349
2350            let style = if is_highlighted {
2351                Style::default()
2352                    .fg(self.theme.menu_highlight_fg)
2353                    .bg(self.theme.menu_highlight_bg)
2354            } else {
2355                Style::default()
2356                    .fg(self.theme.menu_dropdown_fg)
2357                    .bg(self.theme.menu_dropdown_bg)
2358            };
2359
2360            // Pad the label to fill the menu width
2361            let label = item.label();
2362            let content_width = (menu_width as usize).saturating_sub(2); // -2 for borders
2363            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
2364
2365            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
2366        }
2367
2368        let block = Block::default()
2369            .borders(Borders::ALL)
2370            .border_style(Style::default().fg(self.theme.menu_border_fg))
2371            .style(Style::default().bg(self.theme.menu_dropdown_bg));
2372
2373        let paragraph = Paragraph::new(lines).block(block);
2374        frame.render_widget(paragraph, area);
2375    }
2376
2377    /// Render the file explorer context menu
2378    fn render_file_explorer_context_menu(
2379        &self,
2380        frame: &mut Frame,
2381        menu: &super::types::FileExplorerContextMenu,
2382    ) {
2383        use ratatui::style::Style;
2384        use ratatui::text::{Line, Span};
2385        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2386
2387        let items = menu.items();
2388        let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
2389        let menu_height = menu.height();
2390        let (menu_x, menu_y) = menu.clamped_position(frame.area().width, frame.area().height);
2391
2392        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
2393
2394        frame.render_widget(Clear, area);
2395
2396        let mut lines = Vec::new();
2397        for (idx, item) in items.iter().enumerate() {
2398            let is_highlighted = idx == menu.highlighted;
2399
2400            let style = if is_highlighted {
2401                Style::default()
2402                    .fg(self.theme.menu_highlight_fg)
2403                    .bg(self.theme.menu_highlight_bg)
2404            } else {
2405                Style::default()
2406                    .fg(self.theme.menu_dropdown_fg)
2407                    .bg(self.theme.menu_dropdown_bg)
2408            };
2409
2410            let label = item.label();
2411            let content_width = (menu_width as usize).saturating_sub(2);
2412            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
2413
2414            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
2415        }
2416
2417        let block = Block::default()
2418            .borders(Borders::ALL)
2419            .border_style(Style::default().fg(self.theme.menu_border_fg))
2420            .style(Style::default().bg(self.theme.menu_dropdown_bg));
2421
2422        let paragraph = Paragraph::new(lines).block(block);
2423        frame.render_widget(paragraph, area);
2424    }
2425
2426    /// Render the tab drag drop zone overlay
2427    fn render_tab_drop_zone(&self, frame: &mut Frame, drag_state: &super::types::TabDragState) {
2428        use ratatui::style::Modifier;
2429
2430        let Some(ref drop_zone) = drag_state.drop_zone else {
2431            return;
2432        };
2433
2434        let split_id = drop_zone.split_id();
2435
2436        // Find the content area for the target split
2437        let split_area = self
2438            .cached_layout
2439            .split_areas
2440            .iter()
2441            .find(|(sid, _, _, _, _, _)| *sid == split_id)
2442            .map(|(_, _, content_rect, _, _, _)| *content_rect);
2443
2444        let Some(content_rect) = split_area else {
2445            return;
2446        };
2447
2448        // Determine the highlight area based on drop zone type
2449        use super::types::TabDropZone;
2450
2451        let highlight_area = match drop_zone {
2452            TabDropZone::TabBar(_, _) | TabDropZone::SplitCenter(_) => {
2453                // For tab bar and center drops, highlight the entire split area
2454                // This indicates the tab will be added to this split's tab bar
2455                content_rect
2456            }
2457            TabDropZone::SplitLeft(_) => {
2458                // Left 50% of the split (matches the actual split size created)
2459                let width = (content_rect.width / 2).max(3);
2460                ratatui::layout::Rect::new(
2461                    content_rect.x,
2462                    content_rect.y,
2463                    width,
2464                    content_rect.height,
2465                )
2466            }
2467            TabDropZone::SplitRight(_) => {
2468                // Right 50% of the split (matches the actual split size created)
2469                let width = (content_rect.width / 2).max(3);
2470                let x = content_rect.x + content_rect.width - width;
2471                ratatui::layout::Rect::new(x, content_rect.y, width, content_rect.height)
2472            }
2473            TabDropZone::SplitTop(_) => {
2474                // Top 50% of the split (matches the actual split size created)
2475                let height = (content_rect.height / 2).max(2);
2476                ratatui::layout::Rect::new(
2477                    content_rect.x,
2478                    content_rect.y,
2479                    content_rect.width,
2480                    height,
2481                )
2482            }
2483            TabDropZone::SplitBottom(_) => {
2484                // Bottom 50% of the split (matches the actual split size created)
2485                let height = (content_rect.height / 2).max(2);
2486                let y = content_rect.y + content_rect.height - height;
2487                ratatui::layout::Rect::new(content_rect.x, y, content_rect.width, height)
2488            }
2489        };
2490
2491        // Draw the overlay with the drop zone color
2492        // We apply a semi-transparent effect by modifying existing cells
2493        let buf = frame.buffer_mut();
2494        let drop_zone_bg = self.theme.tab_drop_zone_bg;
2495        let drop_zone_border = self.theme.tab_drop_zone_border;
2496
2497        // Fill the highlight area with a semi-transparent overlay
2498        for y in highlight_area.y..highlight_area.y + highlight_area.height {
2499            for x in highlight_area.x..highlight_area.x + highlight_area.width {
2500                if let Some(cell) = buf.cell_mut((x, y)) {
2501                    // Blend the drop zone color with the existing background
2502                    // For a simple effect, we just set the background
2503                    cell.set_bg(drop_zone_bg);
2504
2505                    // Draw border on edges
2506                    let is_border = x == highlight_area.x
2507                        || x == highlight_area.x + highlight_area.width - 1
2508                        || y == highlight_area.y
2509                        || y == highlight_area.y + highlight_area.height - 1;
2510
2511                    if is_border {
2512                        cell.set_fg(drop_zone_border);
2513                        cell.set_style(cell.style().add_modifier(Modifier::BOLD));
2514                    }
2515                }
2516            }
2517        }
2518
2519        // Draw a border indicator based on the zone type
2520        match drop_zone {
2521            TabDropZone::SplitLeft(_) => {
2522                // Draw vertical indicator on left edge
2523                for y in highlight_area.y..highlight_area.y + highlight_area.height {
2524                    if let Some(cell) = buf.cell_mut((highlight_area.x, y)) {
2525                        cell.set_symbol("▌");
2526                        cell.set_fg(drop_zone_border);
2527                    }
2528                }
2529            }
2530            TabDropZone::SplitRight(_) => {
2531                // Draw vertical indicator on right edge
2532                let x = highlight_area.x + highlight_area.width - 1;
2533                for y in highlight_area.y..highlight_area.y + highlight_area.height {
2534                    if let Some(cell) = buf.cell_mut((x, y)) {
2535                        cell.set_symbol("▐");
2536                        cell.set_fg(drop_zone_border);
2537                    }
2538                }
2539            }
2540            TabDropZone::SplitTop(_) => {
2541                // Draw horizontal indicator on top edge
2542                for x in highlight_area.x..highlight_area.x + highlight_area.width {
2543                    if let Some(cell) = buf.cell_mut((x, highlight_area.y)) {
2544                        cell.set_symbol("▀");
2545                        cell.set_fg(drop_zone_border);
2546                    }
2547                }
2548            }
2549            TabDropZone::SplitBottom(_) => {
2550                // Draw horizontal indicator on bottom edge
2551                let y = highlight_area.y + highlight_area.height - 1;
2552                for x in highlight_area.x..highlight_area.x + highlight_area.width {
2553                    if let Some(cell) = buf.cell_mut((x, y)) {
2554                        cell.set_symbol("▄");
2555                        cell.set_fg(drop_zone_border);
2556                    }
2557                }
2558            }
2559            TabDropZone::SplitCenter(_) | TabDropZone::TabBar(_, _) => {
2560                // For center and tab bar, the filled background is sufficient
2561            }
2562        }
2563    }
2564
2565    /// Recompute the view_line_mappings layout without drawing.
2566    /// Used during macro replay so that visual-line movements (MoveLineEnd,
2567    /// MoveUp, MoveDown on wrapped lines) see correct, up-to-date layout
2568    /// information between each replayed action.
2569    pub fn recompute_layout(&mut self, width: u16, height: u16) {
2570        let size = ratatui::layout::Rect::new(0, 0, width, height);
2571
2572        // Replicate the pre-render sync steps from render()
2573        let active_split = self.split_manager.active_split();
2574        self.pre_sync_ensure_visible(active_split);
2575        self.sync_scroll_groups();
2576
2577        // Replicate the layout computation that produces editor_content_area.
2578        // Same constraints as render(): [menu_bar, main_content, status_bar, search_options, prompt_line]
2579        let constraints = vec![
2580            Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }),
2581            Constraint::Min(0),
2582            Constraint::Length(if self.status_bar_visible { 1 } else { 0 }), // status bar
2583            Constraint::Length(0), // search options (doesn't matter for layout)
2584            Constraint::Length(if self.prompt_line_visible { 1 } else { 0 }), // prompt line
2585        ];
2586        let main_chunks = Layout::default()
2587            .direction(Direction::Vertical)
2588            .constraints(constraints)
2589            .split(size);
2590        let main_content_area = main_chunks[1];
2591
2592        // Compute editor_content_area (with file explorer split if visible)
2593        let file_explorer_should_show = self.file_explorer_visible
2594            && (self.file_explorer.is_some() || self.file_explorer_sync_in_progress);
2595        let editor_content_area = if file_explorer_should_show {
2596            let explorer_cols = self.file_explorer_width.to_cols(main_content_area.width);
2597            let horizontal_chunks = Layout::default()
2598                .direction(Direction::Horizontal)
2599                .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
2600                .split(main_content_area);
2601            horizontal_chunks[1]
2602        } else {
2603            main_content_area
2604        };
2605
2606        // Compute layout for all visible splits and update cached view_line_mappings
2607        let view_line_mappings = SplitRenderer::compute_content_layout(
2608            editor_content_area,
2609            &self.split_manager,
2610            &mut self.buffers,
2611            &mut self.split_view_states,
2612            &self.theme,
2613            false, // lsp_waiting — not relevant for layout
2614            self.config.editor.estimated_line_length,
2615            self.config.editor.highlight_context_bytes,
2616            self.config.editor.relative_line_numbers,
2617            self.config.editor.use_terminal_bg,
2618            self.session_mode || !self.software_cursor_only,
2619            self.software_cursor_only,
2620            self.tab_bar_visible,
2621            self.config.editor.show_vertical_scrollbar,
2622            self.config.editor.show_horizontal_scrollbar,
2623            self.config.editor.diagnostics_inline_text,
2624            self.config.editor.show_tilde,
2625        );
2626
2627        self.cached_layout.view_line_mappings = view_line_mappings;
2628    }
2629
2630    /// Clear the search history
2631    /// Used primarily for testing to ensure test isolation
2632    pub fn clear_search_history(&mut self) {
2633        if let Some(history) = self.prompt_histories.get_mut("search") {
2634            history.clear();
2635        }
2636    }
2637
2638    /// Emit an OSC 2 escape sequence to set the host terminal's window/tab
2639    /// title based on the active buffer's display name and the project name
2640    /// (the working directory's last path component). Deduplicated against
2641    /// the last title we wrote so we don't spam stdout every frame.
2642    ///
2643    /// Gated by `editor.set_window_title` (default on). Terminals that
2644    /// don't implement OSC 2 silently drop the sequence.
2645    fn update_terminal_title(&mut self, display_name: &str) {
2646        if !self.config.editor.set_window_title {
2647            return;
2648        }
2649        let project_name = self.working_dir.file_name().and_then(|s| s.to_str());
2650        let new_title =
2651            crate::services::terminal_title::build_window_title(display_name, project_name);
2652        if self.last_window_title.as_deref() == Some(new_title.as_str()) {
2653            return;
2654        }
2655        crate::services::terminal_title::write_terminal_title(&new_title);
2656        self.last_window_title = Some(new_title);
2657    }
2658
2659    /// Save all prompt histories to disk
2660    /// Called on shutdown to persist history across sessions
2661    pub fn save_histories(&self) {
2662        // Ensure data directory exists
2663        if let Err(e) = self
2664            .authority
2665            .filesystem
2666            .create_dir_all(&self.dir_context.data_dir)
2667        {
2668            tracing::warn!("Failed to create data directory: {}", e);
2669            return;
2670        }
2671
2672        // Save all prompt histories
2673        for (key, history) in &self.prompt_histories {
2674            let path = self.dir_context.prompt_history_path(key);
2675            if let Err(e) = history.save_to_file(&path) {
2676                tracing::warn!("Failed to save {} history: {}", key, e);
2677            } else {
2678                tracing::debug!("Saved {} history to {:?}", key, path);
2679            }
2680        }
2681    }
2682}