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 topmost global popup at its computed area and register its
7    /// click region in `global_popup_areas`. Shared by the generic
8    /// global-popup slot and the workspace-trust modal band so the area math
9    /// lives in exactly one place.
10    fn render_top_global_popup(
11        &mut self,
12        frame: &mut Frame,
13        size: ratatui::layout::Rect,
14        theme: &crate::view::theme::Theme,
15        hover_target: Option<&crate::app::HoverTarget>,
16    ) {
17        let Some(popup) = self.global_popups.top() else {
18            return;
19        };
20        let top_idx = self.global_popups.all().len() - 1;
21        let popup_area = popup.calculate_area(size, None);
22        let desc_height = popup.description_height();
23        let inner_area = if popup.bordered {
24            ratatui::layout::Rect {
25                x: popup_area.x + 1,
26                y: popup_area.y + 1 + desc_height,
27                width: popup_area.width.saturating_sub(2),
28                height: popup_area.height.saturating_sub(2 + desc_height),
29            }
30        } else {
31            ratatui::layout::Rect {
32                x: popup_area.x,
33                y: popup_area.y + desc_height,
34                width: popup_area.width,
35                height: popup_area.height.saturating_sub(desc_height),
36            }
37        };
38        let num_items = match &popup.content {
39            crate::view::popup::PopupContent::List { items, .. } => items.len(),
40            _ => 0,
41        };
42        let scroll_offset = popup.scroll_offset;
43        popup.render_with_hover(frame, popup_area, theme, hover_target);
44        self.active_chrome_mut().global_popup_areas.push((
45            top_idx,
46            popup_area,
47            inner_area,
48            scroll_offset,
49            num_items,
50        ));
51    }
52
53    /// Render the editor to the terminal
54    pub fn render(&mut self, frame: &mut Frame) {
55        let _span = tracing::info_span!("render").entered();
56        let size = frame.area();
57
58        // Let active animations snapshot the previous frame's buffer
59        // from the runner's own cache. We can't read the live
60        // `frame.buffer_mut()` — ratatui resets it before each draw —
61        // so the runner keeps a post-apply clone from the last frame.
62        self.active_window_mut().animations.capture_before_all();
63
64        // Save frame dimensions for recompute_layout (used by macro replay)
65        self.active_chrome_mut().last_frame_width = size.width;
66        self.active_chrome_mut().last_frame_height = size.height;
67
68        // Reset per-cell theme key map for this frame
69        self.active_chrome_mut().reset_cell_theme_map();
70
71        // For scroll sync groups, we need to update the active split's viewport position BEFORE
72        // calling sync_scroll_groups, so that the sync reads the correct position.
73        // Otherwise, cursor movements like 'G' (go to end) won't sync properly because
74        // viewport.top_byte hasn't been updated yet.
75        let active_split = self
76            .windows
77            .get(&self.active_window)
78            .and_then(|w| w.buffers.splits())
79            .map(|(mgr, _)| mgr)
80            .expect("active window must have a populated split layout")
81            .active_split();
82        {
83            let _span = tracing::info_span!("pre_sync_ensure_visible").entered();
84            self.active_window_mut()
85                .pre_sync_ensure_visible(active_split);
86        }
87
88        // Synchronize scroll sync groups (anchor-based scroll for side-by-side diffs)
89        // This sets viewport positions based on the authoritative scroll_line in each group
90        {
91            let _span = tracing::info_span!("sync_scroll_groups").entered();
92            self.active_window_mut().sync_scroll_groups();
93        }
94
95        // NOTE: Viewport sync with cursor is handled by split_rendering.rs which knows the
96        // correct content area dimensions. Don't sync here with incorrect EditorState viewport size.
97
98        // Prepare all buffers for rendering (pre-load viewport data for lazy loading)
99        // Each split may have a different viewport position on the same buffer
100        let mut semantic_ranges: std::collections::HashMap<BufferId, (usize, usize)> =
101            std::collections::HashMap::new();
102        {
103            let _span = tracing::info_span!("compute_semantic_ranges").entered();
104            for (split_id, view_state) in self
105                .windows
106                .get(&self.active_window)
107                .and_then(|w| w.buffers.splits())
108                .map(|(_, vs)| vs)
109                .expect("active window must have a populated split layout")
110            {
111                if let Some(buffer_id) = self
112                    .windows
113                    .get(&self.active_window)
114                    .and_then(|w| w.buffers.splits())
115                    .map(|(mgr, _)| mgr)
116                    .expect("active window must have a populated split layout")
117                    .get_buffer_id((*split_id).into())
118                {
119                    if let Some(state) = self
120                        .windows
121                        .get(&self.active_window)
122                        .map(|w| &w.buffers)
123                        .expect("active window present")
124                        .get(&buffer_id)
125                    {
126                        let start_line = state.buffer.get_line_number(view_state.viewport.top_byte);
127                        let visible_lines =
128                            view_state.viewport.visible_line_count().saturating_sub(1);
129                        let end_line = start_line.saturating_add(visible_lines);
130                        semantic_ranges
131                            .entry(buffer_id)
132                            .and_modify(|(min_start, max_end)| {
133                                *min_start = (*min_start).min(start_line);
134                                *max_end = (*max_end).max(end_line);
135                            })
136                            .or_insert((start_line, end_line));
137                    }
138                }
139            }
140        }
141        for (buffer_id, (start_line, end_line)) in semantic_ranges {
142            self.maybe_request_semantic_tokens_range(buffer_id, start_line, end_line);
143            self.maybe_request_semantic_tokens_full_debounced(buffer_id);
144            self.maybe_request_folding_ranges_debounced(buffer_id);
145        }
146
147        {
148            let _span = tracing::info_span!("prepare_for_render").entered();
149            // Pre-collect (split_id, top_byte, height, buffer_id) so we
150            // can mutate buffers below without holding a read borrow on
151            // self.windows.
152            let active_id = self.active_window;
153            let prep_targets: Vec<(BufferId, usize, u16)> = {
154                let win = self
155                    .windows
156                    .get(&active_id)
157                    .expect("active window must exist");
158                let (mgr, vs_map) = win
159                    .buffers
160                    .splits()
161                    .expect("active window must have a populated split layout");
162                vs_map
163                    .iter()
164                    .filter_map(|(split_id, vs)| {
165                        mgr.get_buffer_id((*split_id).into())
166                            .map(|bid| (bid, vs.viewport.top_byte, vs.viewport.height))
167                    })
168                    .collect()
169            };
170            let win_buffers = &mut self
171                .windows
172                .get_mut(&active_id)
173                .expect("active window must exist")
174                .buffers;
175            for (buffer_id, top_byte, height) in prep_targets {
176                if let Some(state) = win_buffers.get_mut(&buffer_id) {
177                    if let Err(e) = state.prepare_for_render(top_byte, height) {
178                        tracing::error!("Failed to prepare buffer for render: {}", e);
179                    }
180                }
181            }
182        }
183
184        // Refresh search highlights only during incremental search (when prompt is active)
185        // After search is confirmed, overlays exist for ALL matches and shouldn't be overwritten
186        let is_search_prompt_active = self.active_window().prompt.as_ref().is_some_and(|p| {
187            matches!(
188                p.prompt_type,
189                PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch
190            )
191        });
192        if is_search_prompt_active {
193            if let Some(ref search_state) = self.active_window().search_state {
194                let query = search_state.query.clone();
195                self.update_search_highlights(&query);
196            }
197        }
198
199        // Determine if we need to show search options bar.
200        // (Held in mutable bindings because the in-render
201        // `process_commands` block below can dispatch commands —
202        // e.g. `StartPromptAsync`, `SetPromptSuggestions` — that
203        // mutate `self.active_window_mut().prompt`. When that happens we recompute these
204        // flags and re-split `main_chunks` so the bottom-row
205        // rendering uses an up-to-date layout. See the
206        // "Recompute layout if mid-render commands changed state"
207        // block below.)
208        let mut show_search_options = self.active_window().prompt.as_ref().is_some_and(|p| {
209            matches!(
210                p.prompt_type,
211                PromptType::Search
212                    | PromptType::ReplaceSearch
213                    | PromptType::Replace { .. }
214                    | PromptType::QueryReplaceSearch
215                    | PromptType::QueryReplace { .. }
216            )
217        });
218
219        // Hide status bar when suggestions popup or file browser
220        // popup is shown — those popups float just above the prompt
221        // line, and a visible status bar wedged between them looks
222        // wrong. Floating-overlay prompts (Live Grep, issue #1796)
223        // are exempt because their suggestions live inside the
224        // centred frame, not above the bottom row.
225        let mut prompt_is_overlay = self
226            .active_window()
227            .prompt
228            .as_ref()
229            .is_some_and(|p| p.overlay);
230        let mut has_suggestions = self
231            .active_window()
232            .prompt
233            .as_ref()
234            .is_some_and(|p| !p.suggestions.is_empty())
235            && !prompt_is_overlay;
236        let mut has_file_browser = self.active_window().prompt.as_ref().is_some_and(|p| {
237            matches!(
238                p.prompt_type,
239                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
240            )
241        }) && self.active_window_mut().file_open_state.is_some();
242
243        // Build main vertical layout: [menu_bar, main_content, status_bar, search_options, prompt_line]
244        // Status bar is hidden when suggestions popup is shown
245        // Search options bar is shown when in search prompt
246        let mut main_chunks = Layout::default()
247            .direction(Direction::Vertical)
248            .constraints(vec![
249                Constraint::Length(if self.active_window_mut().menu_bar_visible {
250                    1
251                } else {
252                    0
253                }), // Menu bar
254                Constraint::Min(0), // Main content area
255                Constraint::Length(
256                    if !self.active_window_mut().status_bar_visible
257                        || has_suggestions
258                        || has_file_browser
259                    {
260                        0
261                    } else {
262                        1
263                    },
264                ), // Status bar (hidden when toggled off or with popups)
265                Constraint::Length(if show_search_options { 1 } else { 0 }), // Search options bar
266                Constraint::Length(
267                    // Prompt line is auto-hidden when no prompt active.
268                    // Overlay prompts (Live Grep, issue #1796) host the
269                    // input row inside the centred frame, so the
270                    // bottom row stays available for editor content
271                    // rather than being reserved as dead space.
272                    if (self.active_window_mut().prompt_line_visible
273                        || self.active_window().prompt.is_some())
274                        && !prompt_is_overlay
275                    {
276                        1
277                    } else {
278                        0
279                    },
280                ), // Prompt line
281            ])
282            .split(size);
283
284        let menu_bar_area = main_chunks[0];
285        let main_content_area = main_chunks[1];
286        let status_bar_idx = 2;
287        let search_options_idx = 3;
288        let prompt_line_idx = 4;
289
290        // Split main content area based on file explorer visibility
291        // Also keep the layout split if a sync is in progress (to avoid flicker)
292        let editor_content_area;
293        let file_explorer_should_show = self.file_explorer_visible()
294            && (self.file_explorer().is_some()
295                || self.active_window().file_explorer_sync_in_progress);
296
297        if file_explorer_should_show {
298            // Split horizontally based on side placement
299            tracing::trace!(
300                "render: file explorer layout active (present={}, sync_in_progress={}, side={:?})",
301                self.file_explorer().is_some(),
302                self.active_window().file_explorer_sync_in_progress,
303                self.active_window().file_explorer_side
304            );
305            let explorer_cols = self
306                .active_window()
307                .file_explorer_width
308                .to_cols(main_content_area.width);
309
310            let (explorer_area, editor_area) = match self.active_window().file_explorer_side {
311                FileExplorerSide::Left => {
312                    let chunks = Layout::default()
313                        .direction(Direction::Horizontal)
314                        .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
315                        .split(main_content_area);
316                    (chunks[0], chunks[1])
317                }
318                FileExplorerSide::Right => {
319                    let chunks = Layout::default()
320                        .direction(Direction::Horizontal)
321                        .constraints([Constraint::Min(0), Constraint::Length(explorer_cols)])
322                        .split(main_content_area);
323                    (chunks[1], chunks[0])
324                }
325            };
326
327            self.active_layout_mut().file_explorer_area = Some(explorer_area);
328            editor_content_area = editor_area;
329
330            // Get connection string before mutable borrow of file_explorer.
331            let remote_connection = self.connection_display_string();
332
333            // Render file explorer (only if we have it - during sync we just keep the area reserved).
334            // Uses direct `self.windows.get_mut(...)` (not `file_explorer_mut()`) so the body
335            // can keep reading other Editor fields (buffers, theme, keybindings, …) — Rust
336            // splits the borrow on `self.windows` from the borrows on those other fields.
337            let active_id = self.active_window;
338            // Read window-state inputs before taking the &mut borrow on the
339            // window for the explorer/buffer access below.
340            let is_focused = self.active_window().key_context == KeyContext::FileExplorer;
341            let key_context_clone = self.active_window().key_context.clone();
342            let close_button_hovered = matches!(
343                &self.active_window().mouse_state.hover_target,
344                Some(HoverTarget::FileExplorerCloseButton)
345            );
346            // Take one &mut on the active window; the explorer + buffers
347            // come from disjoint sub-fields so they can coexist.
348            let __win = self
349                .windows
350                .get_mut(&active_id)
351                .expect("active window must exist");
352            let __buffers_ref: &crate::app::window::WindowBuffers = &__win.buffers;
353            if let Some(explorer) = __win.file_explorer.as_mut() {
354                // Build set of files with unsaved changes
355                let mut files_with_unsaved_changes = std::collections::HashSet::new();
356                for (buffer_id, state) in __buffers_ref {
357                    if state.buffer.is_modified() {
358                        if let Some(metadata) = __win.buffer_metadata.get(buffer_id) {
359                            if let Some(file_path) = metadata.file_path() {
360                                files_with_unsaved_changes.insert(file_path.clone());
361                            }
362                        }
363                    }
364                }
365
366                let keybindings = self.keybindings.read().unwrap();
367                let empty: Vec<std::path::PathBuf> = Vec::new();
368                let cut_paths = __win
369                    .file_explorer_clipboard
370                    .as_ref()
371                    .filter(|cb| cb.is_cut)
372                    .map(|cb| cb.paths.as_slice())
373                    .unwrap_or(empty.as_slice());
374                FileExplorerRenderer::render(
375                    explorer,
376                    frame,
377                    explorer_area,
378                    is_focused,
379                    &files_with_unsaved_changes,
380                    &__win.file_explorer_decoration_cache,
381                    &keybindings,
382                    key_context_clone,
383                    &*self.theme.read().unwrap(),
384                    close_button_hovered,
385                    remote_connection.as_deref(),
386                    cut_paths,
387                    &self.config.file_explorer.tree_indicator_collapsed,
388                    &self.config.file_explorer.tree_indicator_expanded,
389                );
390            }
391            // Note: if file_explorer is None but sync_in_progress is true,
392            // we just leave the area blank (or could render a placeholder)
393        } else {
394            // No file explorer: use entire main content area for editor
395            self.active_layout_mut().file_explorer_area = None;
396            editor_content_area = main_content_area;
397        }
398
399        // Note: Tabs are now rendered within each split by SplitRenderer
400
401        // Trigger lines_changed hooks for newly visible lines in all visible buffers
402        // This allows plugins to add overlays before rendering
403        // Only lines that haven't been seen before are sent (batched for efficiency)
404        // Use non-blocking hooks to avoid deadlock when actions are awaiting
405        if self.plugin_manager.read().unwrap().is_active() {
406            let hooks_start = std::time::Instant::now();
407            // Get visible buffers and their areas
408            let visible_buffers = self
409                .windows
410                .get(&self.active_window)
411                .and_then(|w| w.buffers.splits())
412                .map(|(mgr, _)| mgr)
413                .expect("active window must have a populated split layout")
414                .get_visible_buffers(editor_content_area);
415
416            let mut total_new_lines = 0usize;
417            for (split_id, buffer_id, split_area) in visible_buffers {
418                // Get viewport from SplitViewState (the authoritative source)
419                let viewport_top_byte = self
420                    .windows
421                    .get(&self.active_window)
422                    .and_then(|w| w.buffers.splits())
423                    .map(|(_, vs)| vs)
424                    .expect("active window must have a populated split layout")
425                    .get(&split_id)
426                    .map(|vs| vs.viewport.top_byte)
427                    .unwrap_or(0);
428
429                let __active_id = self.active_window;
430                let __win = self
431                    .windows
432                    .get_mut(&__active_id)
433                    .expect("active window must exist");
434                // Take a disjoint mut borrow on `seen_byte_ranges` (a sibling
435                // field on Window, not part of WindowBuffers) so the closure
436                // below can update it alongside the buffer + view-state
437                // mutations.
438                let seen_ranges_for_win = &mut __win.seen_byte_ranges;
439                let plugin_manager = &self.plugin_manager;
440                let estimated_line_length = self.config.editor.estimated_line_length;
441                let added = __win
442                    .buffers
443                    .with_buffer_and_view_states(buffer_id, |state, vs_map| {
444                        // `render_start` has a tiny payload (just the
445                        // buffer id) — fire unconditionally so third-party
446                        // plugins listening for it still work.
447                        let pm_guard = plugin_manager.read().unwrap();
448                        pm_guard.run_hook(
449                            "render_start",
450                            crate::services::plugins::hooks::HookArgs::RenderStart { buffer_id },
451                        );
452
453                        let visible_count = split_area.height as usize;
454
455                        // `view_transform_request` carries the full
456                        // tokenized viewport in its args. Building those
457                        // tokens (`build_base_tokens_for_hook`) is the
458                        // expensive part — see #2009. Skip the whole
459                        // pipeline when no plugin subscribes.
460                        if pm_guard.has_subscribers("view_transform_request") {
461                            let is_binary = state.buffer.is_binary();
462                            let line_ending = state.buffer.line_ending();
463                            let base_tokens =
464                                crate::view::ui::split_rendering::SplitRenderer::build_base_tokens_for_hook(
465                                    &mut state.buffer,
466                                    viewport_top_byte,
467                                    estimated_line_length,
468                                    visible_count,
469                                    is_binary,
470                                    line_ending,
471                                );
472                            let viewport_start = viewport_top_byte;
473                            let viewport_end = base_tokens
474                                .last()
475                                .and_then(|t| t.source_offset)
476                                .unwrap_or(viewport_start);
477                            let cursor_positions: Vec<usize> = vs_map
478                                .get(&split_id)
479                                .map(|vs| vs.cursors.iter().map(|(_, c)| c.position).collect())
480                                .unwrap_or_default();
481                            pm_guard.run_hook(
482                                "view_transform_request",
483                                crate::services::plugins::hooks::HookArgs::ViewTransformRequest {
484                                    buffer_id,
485                                    split_id: split_id.into(),
486                                    viewport_start,
487                                    viewport_end,
488                                    tokens: base_tokens,
489                                    cursor_positions,
490                                },
491                            );
492
493                            // Plugin saw fresh base tokens; future
494                            // SubmitViewTransform from this request is valid.
495                            if let Some(vs) = vs_map.get_mut(&split_id) {
496                                vs.view_transform_stale = false;
497                            }
498                        }
499                        drop(pm_guard);
500
501                        let top_byte = viewport_top_byte;
502                        let seen_byte_ranges =
503                            seen_ranges_for_win.entry(buffer_id).or_default();
504
505                        let mut new_lines: Vec<
506                            crate::services::plugins::hooks::LineInfo,
507                        > = Vec::new();
508                        let mut line_number = state.buffer.get_line_number(top_byte);
509                        let mut iter = state
510                            .buffer
511                            .line_iterator(top_byte, estimated_line_length);
512
513                        for _ in 0..visible_count {
514                            if let Some((line_start, line_content)) = iter.next_line() {
515                                let byte_end = line_start + line_content.len();
516                                let byte_range = (line_start, byte_end);
517
518                                if !seen_byte_ranges.contains(&byte_range) {
519                                    new_lines.push(
520                                        crate::services::plugins::hooks::LineInfo {
521                                            line_number,
522                                            byte_start: line_start,
523                                            byte_end,
524                                            content: line_content,
525                                        },
526                                    );
527                                    seen_byte_ranges.insert(byte_range);
528                                }
529                                line_number += 1;
530                            } else {
531                                break;
532                            }
533                        }
534
535                        let count = new_lines.len();
536                        if !new_lines.is_empty() {
537                            plugin_manager.read().unwrap().run_hook(
538                                "lines_changed",
539                                crate::services::plugins::hooks::HookArgs::LinesChanged {
540                                    buffer_id,
541                                    lines: new_lines,
542                                },
543                            );
544                        }
545                        count
546                    })
547                    .unwrap_or(0);
548                total_new_lines += added;
549            }
550            let hooks_elapsed = hooks_start.elapsed();
551            tracing::trace!(
552                new_lines = total_new_lines,
553                elapsed_ms = hooks_elapsed.as_millis(),
554                elapsed_us = hooks_elapsed.as_micros(),
555                "lines_changed hooks total"
556            );
557
558            // Process any plugin commands (like AddOverlay) that resulted from the hooks.
559            //
560            // This is non-blocking: we collect whatever the plugin has sent so far.
561            // The plugin thread runs in parallel, and because we proactively call
562            // handle_refresh_lines after cursor_moved (in fire_cursor_hooks), the
563            // lines_changed hook fires early in the render cycle. By the time we
564            // reach this point, the plugin has typically already processed all hooks
565            // and sent back conceal/overlay commands. On rare occasions (high CPU
566            // load), the response arrives one frame late, which is imperceptible
567            // at 60fps. The plugin's own refreshLines() call from cursor_moved
568            // ensures a follow-up render cycle picks up any missed commands.
569            let commands = self.plugin_manager.write().unwrap().process_commands();
570            let dispatched_any = !commands.is_empty();
571            if dispatched_any {
572                let cmd_names: Vec<String> =
573                    commands.iter().map(|c| c.debug_variant_name()).collect();
574                tracing::trace!(count = commands.len(), cmds = ?cmd_names, "process_commands during render");
575            }
576            for command in commands {
577                if let Err(e) = self.handle_plugin_command(command) {
578                    tracing::error!("Error handling plugin command: {}", e);
579                }
580            }
581
582            // Flush any deferred grammar rebuilds as a single batch
583            self.flush_pending_grammars();
584
585            // Recompute the bottom-row layout if the in-render command
586            // dispatch above mutated state that affects it. Without
587            // this, a `StartPromptAsync` (or similar) processed
588            // mid-render leaves `main_chunks` reflecting the prior
589            // `self.active_window_mut().prompt = None` shape — the prompt slot ends up at
590            // (y = size.height, h = 0) and the status bar paints the
591            // bottom row in place of the prompt input. Conservative:
592            // we recompute on *any* dispatched commands rather than
593            // enumerating layout-affecting variants — Layout::split is
594            // cheap, and this avoids a maintenance-burden whitelist
595            // that would silently regress as new `PluginCommand`
596            // variants are added.
597            //
598            // Bounded — single drain + single recompute. We do not
599            // call `process_commands` again, so commands queued by
600            // hooks fired inside the dispatch above wait for the next
601            // render or `editor_tick` (the existing one-frame-late
602            // behaviour the comment above already accepts).
603            //
604            // `main_content_area` (and the file-explorer / split
605            // rendering derived from it earlier in this render) is
606            // intentionally NOT re-derived: those areas were already
607            // painted, and the bottom-row recompute may overwrite a
608            // single row of main content where the new status bar /
609            // prompt now sits. That brief overlap self-corrects on
610            // the next frame, where the layout is built consistently
611            // from the start.
612            if dispatched_any {
613                show_search_options = self.active_window().prompt.as_ref().is_some_and(|p| {
614                    matches!(
615                        p.prompt_type,
616                        PromptType::Search
617                            | PromptType::ReplaceSearch
618                            | PromptType::Replace { .. }
619                            | PromptType::QueryReplaceSearch
620                            | PromptType::QueryReplace { .. }
621                    )
622                });
623                prompt_is_overlay = self
624                    .active_window()
625                    .prompt
626                    .as_ref()
627                    .is_some_and(|p| p.overlay);
628                has_suggestions = self
629                    .active_window()
630                    .prompt
631                    .as_ref()
632                    .is_some_and(|p| !p.suggestions.is_empty())
633                    && !prompt_is_overlay;
634                has_file_browser = self.active_window().prompt.as_ref().is_some_and(|p| {
635                    matches!(
636                        p.prompt_type,
637                        PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
638                    )
639                }) && self.active_window_mut().file_open_state.is_some();
640                main_chunks = Layout::default()
641                    .direction(Direction::Vertical)
642                    .constraints(vec![
643                        Constraint::Length(if self.active_window_mut().menu_bar_visible {
644                            1
645                        } else {
646                            0
647                        }),
648                        Constraint::Min(0),
649                        Constraint::Length(
650                            if !self.active_window_mut().status_bar_visible
651                                || has_suggestions
652                                || has_file_browser
653                            {
654                                0
655                            } else {
656                                1
657                            },
658                        ),
659                        Constraint::Length(if show_search_options { 1 } else { 0 }),
660                        Constraint::Length(
661                            if (self.active_window_mut().prompt_line_visible
662                                || self.active_window().prompt.is_some())
663                                && !prompt_is_overlay
664                            {
665                                1
666                            } else {
667                                0
668                            },
669                        ),
670                    ])
671                    .split(size);
672            }
673        }
674
675        // Render editor content (same for both layouts)
676        let lsp_waiting = !self.active_window().pending_completion_requests.is_empty()
677            || self
678                .active_window()
679                .pending_goto_definition_request
680                .is_some();
681
682        // Hide the hardware cursor when menu is open, file explorer is focused, terminal mode,
683        // or settings UI is open
684        // (the file explorer will set its own cursor position when focused)
685        // (terminal mode renders its own cursor via the terminal emulator)
686        // (settings UI is a modal that doesn't need the editor cursor)
687        // This also causes visual cursor indicators in the editor to be dimmed
688        let settings_visible = self.settings_state.as_ref().is_some_and(|s| s.visible);
689        let hide_cursor = self.menu_state.active_menu.is_some()
690            || self.active_window_mut().key_context == KeyContext::FileExplorer
691            || self.active_window().terminal_mode
692            || settings_visible
693            || self.keybinding_editor.is_some();
694
695        // Convert HoverTarget to tab hover info for rendering
696        let hovered_tab = match &self.active_window_mut().mouse_state.hover_target {
697            Some(HoverTarget::TabName(target, split_id)) => Some((*target, *split_id, false)),
698            Some(HoverTarget::TabCloseButton(target, split_id)) => Some((*target, *split_id, true)),
699            _ => None,
700        };
701
702        // Get hovered close split button
703        let hovered_close_split = match &self.active_window_mut().mouse_state.hover_target {
704            Some(HoverTarget::CloseSplitButton(split_id)) => Some(*split_id),
705            _ => None,
706        };
707
708        // Get hovered maximize split button
709        let hovered_maximize_split = match &self.active_window_mut().mouse_state.hover_target {
710            Some(HoverTarget::MaximizeSplitButton(split_id)) => Some(*split_id),
711            _ => None,
712        };
713
714        let is_maximized = self
715            .windows
716            .get(&self.active_window)
717            .and_then(|w| w.buffers.splits())
718            .map(|(mgr, _)| mgr)
719            .expect("active window must have a populated split layout")
720            .is_maximized();
721
722        // The active split's buffer renderer records where the hardware
723        // cursor *wants* to appear here; we only commit it to the frame at
724        // the very end of this draw pass, after popups have been rendered,
725        // so a popup covering the cursor cell causes the cursor to be
726        // hidden (otherwise the hardware caret would bleed through the
727        // popup).
728        let mut pending_hardware_cursor: Option<(u16, u16)> = None;
729
730        let _content_span = tracing::info_span!("render_content").entered();
731        // Take a single mutable borrow on the active window's splits and
732        // split it into (&SplitManager, &mut HashMap<...>) — Rust can
733        // destructure the tuple, but we can't make two separate
734        // `windows.get`/`windows.get_mut` calls in the same expression.
735        let active_window_id = self.active_window;
736        // Take one &mut on the active window. Split-borrow into
737        // buffers (mut), split_mgr (immutable view of mgr), and
738        // split_view_states (mut) — all disjoint sub-fields.
739        let __win = self
740            .windows
741            .get_mut(&active_window_id)
742            .expect("active window must exist");
743        let __metadata_ref = &__win.buffer_metadata;
744        let __event_logs_mut = &mut __win.event_logs;
745        let __grouped_ref = &__win.grouped_subtrees;
746        let __composite_buffers_mut = &mut __win.composite_buffers;
747        let __composite_view_states_mut = &mut __win.composite_view_states;
748        let __cell_theme_map_mut = &mut __win.chrome_layout.cell_theme_map;
749        let __tab_bar_visible = __win.tab_bar_visible;
750        let (
751            split_areas,
752            tab_layouts,
753            close_split_areas,
754            maximize_split_areas,
755            view_line_mappings,
756            horizontal_scrollbar_areas,
757            grouped_separator_areas,
758        ) = __win
759            .buffers
760            .with_all_mut(|__buffers_mut, __mgr, __vs_map| {
761                SplitRenderer::render_content(
762                    frame,
763                    editor_content_area,
764                    &*__mgr,
765                    __buffers_mut,
766                    __metadata_ref,
767                    __event_logs_mut,
768                    __composite_buffers_mut,
769                    __composite_view_states_mut,
770                    &*self.theme.read().unwrap(),
771                    self.ansi_background.as_ref(),
772                    self.background_fade,
773                    lsp_waiting,
774                    self.config.editor.large_file_threshold_bytes,
775                    self.config.editor.line_wrap,
776                    self.config.editor.estimated_line_length,
777                    self.config.editor.highlight_context_bytes,
778                    Some(__vs_map),
779                    __grouped_ref,
780                    hide_cursor,
781                    hovered_tab,
782                    hovered_close_split,
783                    hovered_maximize_split,
784                    is_maximized,
785                    self.config.editor.relative_line_numbers,
786                    __tab_bar_visible,
787                    self.config.editor.use_terminal_bg,
788                    self.session_mode || !self.software_cursor_only,
789                    self.software_cursor_only,
790                    self.config.editor.show_vertical_scrollbar,
791                    self.config.editor.show_horizontal_scrollbar,
792                    self.config.editor.diagnostics_inline_text,
793                    self.config.editor.show_tilde,
794                    self.config.editor.highlight_current_column,
795                    __cell_theme_map_mut,
796                    size.width,
797                    &mut pending_hardware_cursor,
798                )
799            })
800            .expect("active window must have a populated split layout");
801
802        drop(_content_span);
803
804        // Cursor-jump animation: compare the cursor's screen position to
805        // the prior frame and animate either when the cursor crossed split
806        // panes or moved more than two rows within the same pane. The
807        // trail crosses pane separators when the jump is across splits —
808        // that's the intended "follow the focus" cue.
809        self.maybe_start_cursor_jump_animation(pending_hardware_cursor, active_split);
810
811        // Detect viewport changes and fire hooks
812        // Compare against previous frame's viewport state (stored in self.active_window().previous_viewports)
813        // This correctly detects changes from scroll events that happen before render()
814        if self.plugin_manager.read().unwrap().is_active() {
815            for (split_id, view_state) in self
816                .windows
817                .get(&self.active_window)
818                .and_then(|w| w.buffers.splits())
819                .map(|(_, vs)| vs)
820                .expect("active window must have a populated split layout")
821            {
822                let current = (
823                    view_state.viewport.top_byte,
824                    view_state.viewport.width,
825                    view_state.viewport.height,
826                );
827                // Compare against previous frame's state
828                // Skip new splits (None case) - only fire hooks for established splits
829                // This matches the original behavior where hooks only fire for splits
830                // that existed at the start of render
831                let (changed, previous) =
832                    match self.active_window().previous_viewports.get(split_id) {
833                        Some(previous) => (*previous != current, Some(*previous)),
834                        None => (false, None), // Skip new splits until they're established
835                    };
836                tracing::trace!(
837                    "viewport_changed check: split={:?} current={:?} previous={:?} changed={}",
838                    split_id,
839                    current,
840                    previous,
841                    changed
842                );
843                if changed {
844                    if let Some(buffer_id) = self
845                        .windows
846                        .get(&self.active_window)
847                        .and_then(|w| w.buffers.splits())
848                        .map(|(mgr, _)| mgr)
849                        .expect("active window must have a populated split layout")
850                        .get_buffer_id((*split_id).into())
851                    {
852                        // Compute top_line if line info is available
853                        let top_line = self
854                            .windows
855                            .get(&self.active_window)
856                            .map(|w| &w.buffers)
857                            .expect("active window present")
858                            .get(&buffer_id)
859                            .and_then(|state| {
860                                if state.buffer.line_count().is_some() {
861                                    Some(state.buffer.get_line_number(view_state.viewport.top_byte))
862                                } else {
863                                    None
864                                }
865                            });
866                        tracing::debug!(
867                            "Firing viewport_changed hook: split={:?} buffer={:?} top_byte={} top_line={:?}",
868                            split_id,
869                            buffer_id,
870                            view_state.viewport.top_byte,
871                            top_line
872                        );
873                        self.plugin_manager.read().unwrap().run_hook(
874                            "viewport_changed",
875                            crate::services::plugins::hooks::HookArgs::ViewportChanged {
876                                split_id: (*split_id).into(),
877                                buffer_id,
878                                top_byte: view_state.viewport.top_byte,
879                                top_line,
880                                width: view_state.viewport.width,
881                                height: view_state.viewport.height,
882                            },
883                        );
884                    }
885                }
886            }
887        }
888
889        // Update previous_viewports for next frame's comparison.
890        // Take both `previous_viewports` and the split view-states from
891        // the same `__win` borrow so the iterator and the inserts share
892        // a single mutable borrow on `self.windows`.
893        let __vp_win = self
894            .windows
895            .get_mut(&self.active_window)
896            .expect("active window present");
897        __vp_win.previous_viewports.clear();
898        let (_, __vp_vs_map) = __vp_win
899            .buffers
900            .splits()
901            .expect("active window must have a populated split layout");
902        let snapshot: Vec<(LeafId, (usize, u16, u16))> = __vp_vs_map
903            .iter()
904            .map(|(split_id, view_state)| {
905                (
906                    *split_id,
907                    (
908                        view_state.viewport.top_byte,
909                        view_state.viewport.width,
910                        view_state.viewport.height,
911                    ),
912                )
913            })
914            .collect();
915        for (split_id, vp) in snapshot {
916            __vp_win.previous_viewports.insert(split_id, vp);
917        }
918
919        // Render terminal content on top of split content for terminal buffers.
920        // Active-window path: cursor blinks normally when terminal_mode is on.
921        self.active_window()
922            .render_terminal_splits(frame, &split_areas, true);
923
924        self.active_layout_mut().split_areas = split_areas;
925        self.active_layout_mut().horizontal_scrollbar_areas = horizontal_scrollbar_areas;
926        self.active_layout_mut().tab_layouts = tab_layouts;
927        self.active_layout_mut().close_split_areas = close_split_areas;
928        self.active_layout_mut().maximize_split_areas = maximize_split_areas;
929        self.active_layout_mut().view_line_mappings = view_line_mappings;
930
931        // Promote any deferred virtual-buffer animations whose Rect is now
932        // known. Done here (after split_areas is recomputed, before
933        // apply_all runs at the end of render) so the first frame of the
934        // effect lands on the same paint that made the buffer visible.
935        self.drain_pending_vb_animations();
936        let mut separator_areas = self
937            .split_manager_mut()
938            .get_separators_with_ids(editor_content_area);
939        // Grouped subtrees live in a side-map outside the main split tree, so
940        // their inner separators are not visited by `get_separators_with_ids`
941        // above. The renderer collected them (using the same content rect it
942        // drew them at) — merge so clicks on those rendered columns register.
943        separator_areas.extend(grouped_separator_areas);
944        self.active_layout_mut().separator_areas = separator_areas;
945        self.active_layout_mut().editor_content_area = Some(editor_content_area);
946
947        // Render hover highlights for separators and scrollbars
948        self.render_hover_highlights(frame);
949
950        // Initialize popup/suggestion layout state (rendered after status bar below)
951        self.active_chrome_mut().suggestions_area = None;
952        self.active_chrome_mut().suggestions_outer_area = None;
953        self.active_chrome_mut().prompt_results_area = None;
954        self.active_chrome_mut().prompt_preview_area = None;
955        self.active_window_mut().file_browser_layout = None;
956
957        // Clone all immutable values before the mutable borrow
958        let display_name = self
959            .active_window()
960            .buffer_metadata
961            .get(&self.active_buffer())
962            .map(|m| m.display_name.clone())
963            .unwrap_or_else(|| "[No Name]".to_string());
964
965        // Reflect the active buffer in the terminal window/tab title. Only
966        // writes when the title actually changes so we don't flood stdout
967        // with OSC sequences every frame.
968        self.update_terminal_title(&display_name);
969
970        let status_message = self.active_window().status_message.clone();
971        let plugin_status_message = self.active_window().plugin_status_message.clone();
972        let prompt = self.active_window().prompt.clone();
973        // Compute a simple buffer-aware LSP indicator.
974        // Compose the LSP status-bar segment for the active buffer. This
975        // runs every render — the editor has no precomputed LSP-status
976        // string cached anywhere else, so there is a single source of
977        // truth for what the user sees.
978        //
979        // Priority order (first non-empty wins):
980        //
981        //   1. Active `$/progress` work for this language — e.g.
982        //      "LSP (cpp): indexing (42%)". Conveys the transient
983        //      startup/indexing phase.
984        //   2. A running server — "LSP". Short because detail belongs
985        //      in LSP-specific UI, not the compact status bar pill.
986        //   3. Configured `auto_start=true` servers that haven't started
987        //      (error / crashed / pending) — "LSP off".
988        //   4. Configured `enabled && !auto_start` servers that the user
989        //      has to opt into — "LSP: off (N)".
990        //   5. Nothing.
991        //
992        // Rules 3 and 4 address heuristic eval H-1: without them, a
993        // configured-but-dormant server is indistinguishable from "no
994        // LSP at all."
995        let current_language = self
996            .buffers()
997            .get(&self.active_buffer())
998            .map(|s| s.language.clone())
999            .unwrap_or_default();
1000        let buffer_lsp_disabled_reason = self
1001            .active_window()
1002            .buffer_metadata
1003            .get(&self.active_buffer())
1004            .filter(|m| !m.lsp_enabled)
1005            .and_then(|m| m.lsp_disabled_reason.as_deref());
1006        let (lsp_status, lsp_indicator_state) = compose_lsp_status(
1007            &current_language,
1008            buffer_lsp_disabled_reason,
1009            &self.active_window().lsp_progress,
1010            &self.active_window().lsp_server_statuses,
1011            &self.config.lsp,
1012            &self.active_window().user_dismissed_lsp_languages,
1013        );
1014        let theme = self.theme.read().unwrap().clone();
1015        let keybindings_cloned = self.keybindings.read().unwrap().clone(); // Clone the keybindings
1016        let chord_state_cloned = self.active_window_mut().chord_state.clone(); // Clone the chord state
1017
1018        // Get update availability info
1019        let update_available = self.latest_version().map(|v| v.to_string());
1020
1021        // Render status bar (hidden when toggled off, or when suggestions/file browser popup is shown)
1022        if self.active_window_mut().status_bar_visible && !has_suggestions && !has_file_browser {
1023            // Get warning level for colored indicator (respects config setting)
1024            // LSP warning level is scoped to the current buffer's language
1025            let (warning_level, general_warning_count) =
1026                if self.config.warnings.show_status_indicator {
1027                    let lsp_level = {
1028                        use crate::services::async_bridge::LspServerStatus;
1029                        let mut level = WarningLevel::None;
1030                        for ((lang, _), status) in &self.active_window().lsp_server_statuses {
1031                            if lang == &current_language {
1032                                match status {
1033                                    LspServerStatus::Error => {
1034                                        level = WarningLevel::Error;
1035                                        break;
1036                                    }
1037                                    LspServerStatus::Starting | LspServerStatus::Initializing => {
1038                                        if level != WarningLevel::Error {
1039                                            level = WarningLevel::Warning;
1040                                        }
1041                                    }
1042                                    _ => {}
1043                                }
1044                            }
1045                        }
1046                        level
1047                    };
1048                    (
1049                        lsp_level,
1050                        self.active_window().warning_domains.general.count,
1051                    )
1052                } else {
1053                    (WarningLevel::None, 0)
1054                };
1055
1056            // Compute status bar hover state for styling
1057            use crate::view::ui::status_bar::StatusBarHover;
1058            let status_bar_hover = match &self.active_window_mut().mouse_state.hover_target {
1059                Some(HoverTarget::StatusBarLspIndicator) => StatusBarHover::LspIndicator,
1060                Some(HoverTarget::StatusBarWarningBadge) => StatusBarHover::WarningBadge,
1061                Some(HoverTarget::StatusBarLineEndingIndicator) => {
1062                    StatusBarHover::LineEndingIndicator
1063                }
1064                Some(HoverTarget::StatusBarEncodingIndicator) => StatusBarHover::EncodingIndicator,
1065                Some(HoverTarget::StatusBarLanguageIndicator) => StatusBarHover::LanguageIndicator,
1066                Some(HoverTarget::StatusBarRemoteIndicator) => StatusBarHover::RemoteIndicator,
1067                _ => StatusBarHover::None,
1068            };
1069
1070            let remote_connection = self.connection_display_string();
1071
1072            // Get session name for display (only in session mode)
1073            let session_name = self.session_name().map(|s| s.to_string());
1074
1075            let active_split = self.effective_active_split();
1076            let active_buf = self.active_buffer();
1077            let default_cursors = crate::model::cursor::Cursors::new();
1078            let is_read_only = self
1079                .active_window()
1080                .buffer_metadata
1081                .get(&active_buf)
1082                .map(|m| m.read_only)
1083                .unwrap_or(false);
1084            let is_synthetic_placeholder = self
1085                .active_window()
1086                .buffer_metadata
1087                .get(&active_buf)
1088                .map(|m| m.synthetic_placeholder)
1089                .unwrap_or(false);
1090            // Compute plugin-provided status-bar values before taking the
1091            // mutable window borrow below.
1092            let dynamic_status_bar_elements = self.get_status_bar_element_values(active_buf);
1093            // Single window borrow, split into buffers + cursors so the
1094            // status-bar context can hold both.
1095            let __active_id = self.active_window;
1096            let __win = self
1097                .windows
1098                .get_mut(&__active_id)
1099                .expect("active window must exist");
1100            let status_bar_layout = __win
1101                .buffers
1102                .with_buffer_and_view_states(active_buf, |state, vs_map| {
1103                    let cursors = vs_map
1104                        .get(&active_split)
1105                        .map(|v| &v.cursors)
1106                        .unwrap_or(&default_cursors);
1107                    let mut status_ctx = crate::view::ui::status_bar::StatusBarContext {
1108                        state,
1109                        cursors,
1110                        status_message: &status_message,
1111                        plugin_status_message: &plugin_status_message,
1112                        lsp_status: &lsp_status,
1113                        lsp_indicator_state,
1114                        theme: &theme,
1115                        display_name: &display_name,
1116                        keybindings: &keybindings_cloned,
1117                        chord_state: &chord_state_cloned,
1118                        update_available: update_available.as_deref(),
1119                        warning_level,
1120                        general_warning_count,
1121                        hover: status_bar_hover,
1122                        remote_connection: remote_connection.as_deref(),
1123                        session_name: session_name.as_deref(),
1124                        read_only: is_read_only,
1125                        remote_state_override: self.remote_indicator_override.as_ref(),
1126                        is_synthetic_placeholder,
1127                        // Filled in by `render_status` from the user's
1128                        // status_bar config; the value here is just a
1129                        // safe default for the rare path that builds the
1130                        // ctx but doesn't run `render_status`.
1131                        remote_indicator_on_bar: false,
1132                        dynamic_status_bar_elements: dynamic_status_bar_elements.clone(),
1133                    };
1134                    StatusBarRenderer::render_status_bar(
1135                        frame,
1136                        main_chunks[status_bar_idx],
1137                        &mut status_ctx,
1138                        &self.config.editor.status_bar,
1139                    )
1140                })
1141                .expect("active buffer must be present");
1142
1143            // Store status bar layout for click detection
1144            let status_bar_area = main_chunks[status_bar_idx];
1145            self.active_chrome_mut().status_bar_area =
1146                Some((status_bar_area.y, status_bar_area.x, status_bar_area.width));
1147            self.active_chrome_mut().status_bar_lsp_area = status_bar_layout.lsp_indicator;
1148            self.active_chrome_mut().status_bar_warning_area = status_bar_layout.warning_badge;
1149            self.active_chrome_mut().status_bar_line_ending_area =
1150                status_bar_layout.line_ending_indicator;
1151            self.active_chrome_mut().status_bar_encoding_area =
1152                status_bar_layout.encoding_indicator;
1153            self.active_chrome_mut().status_bar_language_area =
1154                status_bar_layout.language_indicator;
1155            self.active_chrome_mut().status_bar_message_area = status_bar_layout.message_area;
1156            self.active_chrome_mut().status_bar_remote_area = status_bar_layout.remote_indicator;
1157        }
1158
1159        // Render search options bar when in search prompt
1160        if show_search_options {
1161            // Show "Confirm" option only in replace modes
1162            let confirm_each = self.active_window().prompt.as_ref().and_then(|p| {
1163                if matches!(
1164                    p.prompt_type,
1165                    PromptType::ReplaceSearch
1166                        | PromptType::Replace { .. }
1167                        | PromptType::QueryReplaceSearch
1168                        | PromptType::QueryReplace { .. }
1169                ) {
1170                    Some(self.active_window().search_confirm_each)
1171                } else {
1172                    None
1173                }
1174            });
1175
1176            // Determine hover state for search options
1177            use crate::view::ui::status_bar::SearchOptionsHover;
1178            let search_options_hover = match &self.active_window_mut().mouse_state.hover_target {
1179                Some(HoverTarget::SearchOptionCaseSensitive) => SearchOptionsHover::CaseSensitive,
1180                Some(HoverTarget::SearchOptionWholeWord) => SearchOptionsHover::WholeWord,
1181                Some(HoverTarget::SearchOptionRegex) => SearchOptionsHover::Regex,
1182                Some(HoverTarget::SearchOptionConfirmEach) => SearchOptionsHover::ConfirmEach,
1183                _ => SearchOptionsHover::None,
1184            };
1185
1186            let search_options_layout = StatusBarRenderer::render_search_options(
1187                frame,
1188                main_chunks[search_options_idx],
1189                self.active_window().search_case_sensitive,
1190                self.active_window().search_whole_word,
1191                self.active_window().search_use_regex,
1192                confirm_each,
1193                &theme,
1194                &keybindings_cloned,
1195                search_options_hover,
1196            );
1197            self.active_chrome_mut().search_options_layout = Some(search_options_layout);
1198        } else {
1199            self.active_chrome_mut().search_options_layout = None;
1200        }
1201
1202        // Render prompt line if active. Overlay prompts (Live Grep)
1203        // skip the bottom-row render entirely — they paint their own
1204        // input row inside the centred overlay frame, so the user's
1205        // editor view stays unobstructed at the bottom.
1206        if let Some(prompt) = &prompt {
1207            if !prompt.overlay {
1208                // Use specialized renderer for file/folder open prompt to show colorized path
1209                if matches!(
1210                    prompt.prompt_type,
1211                    crate::view::prompt::PromptType::OpenFile
1212                        | crate::view::prompt::PromptType::SwitchProject
1213                ) {
1214                    if let Some(file_open_state) = &self.active_window_mut().file_open_state {
1215                        StatusBarRenderer::render_file_open_prompt(
1216                            frame,
1217                            main_chunks[prompt_line_idx],
1218                            prompt,
1219                            file_open_state,
1220                            &theme,
1221                        );
1222                    } else {
1223                        StatusBarRenderer::render_prompt(
1224                            frame,
1225                            main_chunks[prompt_line_idx],
1226                            prompt,
1227                            &theme,
1228                        );
1229                    }
1230                } else {
1231                    StatusBarRenderer::render_prompt(
1232                        frame,
1233                        main_chunks[prompt_line_idx],
1234                        prompt,
1235                        &theme,
1236                    );
1237                }
1238            }
1239        }
1240
1241        // Float-overlay preview: load the selected match's file (if
1242        // the file changed) and seed the phantom leaf's cursor before
1243        // the renderer reaches it. Done before render_prompt_popups
1244        // because that path immediately needs the leaf's view state.
1245        if self
1246            .active_window()
1247            .prompt
1248            .as_ref()
1249            .is_some_and(|p| p.overlay)
1250        {
1251            self.prepare_overlay_preview();
1252        }
1253
1254        // Render file browser popup or suggestions popup AFTER status bar + prompt,
1255        // so they overlay on top of both (fixes bottom border being overwritten by status bar)
1256        self.render_prompt_popups(frame, main_chunks[prompt_line_idx], size.width);
1257
1258        // Render popups from the active buffer state
1259        // Clone theme to avoid borrow checker issues with active_state_mut()
1260        let theme_clone = self.theme.read().unwrap().clone();
1261        let hover_target = self.active_window_mut().mouse_state.hover_target.clone();
1262
1263        // Clear popup areas and recalculate
1264        self.active_chrome_mut().popup_areas.clear();
1265
1266        // Collect popup information without holding a mutable borrow
1267        let popup_info: Vec<_> = {
1268            // Get viewport from active split's SplitViewState
1269            let active_split = self
1270                .windows
1271                .get(&self.active_window)
1272                .and_then(|w| w.buffers.splits())
1273                .map(|(mgr, _)| mgr)
1274                .expect("active window must have a populated split layout")
1275                .active_split();
1276            let viewport = self
1277                .windows
1278                .get(&self.active_window)
1279                .and_then(|w| w.buffers.splits())
1280                .map(|(_, vs)| vs)
1281                .expect("active window must have a populated split layout")
1282                .get(&active_split)
1283                .map(|vs| vs.viewport.clone());
1284
1285            // Get the content_rect for the active split from the cached layout.
1286            // This is the absolute screen rect (already accounts for file explorer,
1287            // tab bar, scrollbars, etc.). The gutter is rendered inside this rect,
1288            // so we add gutter_width to get the text content origin.
1289            let content_rect = self
1290                .active_layout()
1291                .split_areas
1292                .iter()
1293                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
1294                .map(|(_, _, rect, _, _, _)| *rect);
1295
1296            let primary_cursor = self
1297                .windows
1298                .get(&self.active_window)
1299                .and_then(|w| w.buffers.splits())
1300                .map(|(_, vs)| vs)
1301                .expect("active window must have a populated split layout")
1302                .get(&active_split)
1303                .map(|vs| *vs.cursors.primary());
1304            let state = self.active_state_mut();
1305            if state.popups.is_visible() {
1306                // Get the primary cursor position for popup positioning
1307                let primary_cursor =
1308                    primary_cursor.unwrap_or_else(|| crate::model::cursor::Cursor::new(0));
1309
1310                // Compute gutter width so we know where text content starts
1311                let gutter_width = viewport
1312                    .as_ref()
1313                    .map(|vp| vp.gutter_width(&state.buffer) as u16)
1314                    .unwrap_or(0);
1315
1316                let cursor_screen_pos = viewport
1317                    .as_ref()
1318                    .map(|vp| vp.cursor_screen_position(&mut state.buffer, &primary_cursor))
1319                    .unwrap_or((0, 0));
1320
1321                // For completion popups, compute the word-start screen position so
1322                // the popup aligns with the beginning of the word being completed,
1323                // not the current cursor position.
1324                let word_start_screen_pos = {
1325                    use crate::primitives::word_navigation::find_completion_word_start;
1326                    let word_start =
1327                        find_completion_word_start(&state.buffer, primary_cursor.position);
1328                    let word_start_cursor = crate::model::cursor::Cursor::new(word_start);
1329                    viewport
1330                        .as_ref()
1331                        .map(|vp| vp.cursor_screen_position(&mut state.buffer, &word_start_cursor))
1332                        .unwrap_or((0, 0))
1333                };
1334
1335                // Use content_rect as the single source of truth for the text
1336                // content area origin. content_rect.x is the split's left edge
1337                // (already past the file explorer), content_rect.y is below the
1338                // tab bar. Adding gutter_width gives us the text content start.
1339                let (base_x, base_y) = content_rect
1340                    .map(|r| (r.x + gutter_width, r.y))
1341                    .unwrap_or((gutter_width, 1));
1342
1343                let cursor_screen_pos =
1344                    (cursor_screen_pos.0 + base_x, cursor_screen_pos.1 + base_y);
1345                let word_start_screen_pos = (
1346                    word_start_screen_pos.0 + base_x,
1347                    word_start_screen_pos.1 + base_y,
1348                );
1349
1350                // Collect popup data
1351                state
1352                    .popups
1353                    .all()
1354                    .iter()
1355                    .enumerate()
1356                    .map(|(popup_idx, popup)| {
1357                        // Use word-start x for completion popups, cursor x for others
1358                        let popup_pos = if popup.kind == crate::view::popup::PopupKind::Completion {
1359                            (word_start_screen_pos.0, cursor_screen_pos.1)
1360                        } else {
1361                            cursor_screen_pos
1362                        };
1363                        let popup_area = popup.calculate_area(size, Some(popup_pos));
1364
1365                        // Track popup area for mouse hit testing
1366                        // Account for description height when calculating the list item area
1367                        let desc_height = popup.description_height();
1368                        let inner_area = if popup.bordered {
1369                            ratatui::layout::Rect {
1370                                x: popup_area.x + 1,
1371                                y: popup_area.y + 1 + desc_height,
1372                                width: popup_area.width.saturating_sub(2),
1373                                height: popup_area.height.saturating_sub(2 + desc_height),
1374                            }
1375                        } else {
1376                            ratatui::layout::Rect {
1377                                x: popup_area.x,
1378                                y: popup_area.y + desc_height,
1379                                width: popup_area.width,
1380                                height: popup_area.height.saturating_sub(desc_height),
1381                            }
1382                        };
1383
1384                        let num_items = match &popup.content {
1385                            crate::view::popup::PopupContent::List { items, .. } => items.len(),
1386                            _ => 0,
1387                        };
1388
1389                        // Calculate total content lines and scrollbar rect
1390                        let total_lines = popup.item_count();
1391                        let visible_lines = inner_area.height as usize;
1392                        let scrollbar_rect = if total_lines > visible_lines && inner_area.width > 2
1393                        {
1394                            Some(ratatui::layout::Rect {
1395                                x: inner_area.x + inner_area.width - 1,
1396                                y: inner_area.y,
1397                                width: 1,
1398                                height: inner_area.height,
1399                            })
1400                        } else {
1401                            None
1402                        };
1403
1404                        (
1405                            popup_idx,
1406                            popup_area,
1407                            inner_area,
1408                            popup.scroll_offset,
1409                            num_items,
1410                            scrollbar_rect,
1411                            total_lines,
1412                        )
1413                    })
1414                    .collect()
1415            } else {
1416                Vec::new()
1417            }
1418        };
1419
1420        // Store popup areas for mouse hit testing
1421        self.active_chrome_mut().popup_areas = popup_info.clone();
1422
1423        // Now render popups
1424        let state = self.active_state_mut();
1425        if state.popups.is_visible() {
1426            for (popup_idx, popup) in state.popups.all().iter().enumerate() {
1427                if let Some((_, popup_area, _, _, _, _, _)) = popup_info.get(popup_idx) {
1428                    popup.render_with_hover(
1429                        frame,
1430                        *popup_area,
1431                        &theme_clone,
1432                        hover_target.as_ref(),
1433                    );
1434                }
1435            }
1436        }
1437
1438        // Render editor-level popups (e.g. plugin action popups) on top of any
1439        // buffer content so they stay visible across buffer switches and over
1440        // virtual buffers (Dashboard, diagnostics) that own the whole split.
1441        // These don't need cursor-relative positioning — they all use absolute
1442        // positions like BottomRight or Centered.
1443        //
1444        // Queue semantics: concurrent action popups stack in `global_popups`,
1445        // but only the top one renders & receives input. Deeper popups
1446        // surface as the top is resolved — the alternative (drawing all at
1447        // the same BottomRight slot) makes them illegible.
1448        self.active_chrome_mut().global_popup_areas.clear();
1449        // The workspace-trust prompt is a blocking modal: it renders later in
1450        // the dedicated modal z-band (alongside settings / wizard) on a dimmed
1451        // backdrop, so it can't be lost amongst dashboard/explorer chrome.
1452        // Everything else on the global stack renders here, above buffer content.
1453        let top_is_trust_modal = self.global_popups.top().is_some_and(|p| {
1454            matches!(
1455                p.resolver,
1456                crate::view::popup::PopupResolver::WorkspaceTrust
1457            )
1458        });
1459        if !top_is_trust_modal {
1460            self.render_top_global_popup(frame, size, &theme_clone, hover_target.as_ref());
1461        }
1462
1463        // Render menu bar last so dropdown appears on top of all other content
1464        // Update menu context with current editor state
1465        self.update_menu_context();
1466
1467        // Render settings modal (before menu bar so menus can overlay)
1468        // Check visibility first to avoid borrow conflict with dimming
1469        let settings_visible = self
1470            .settings_state
1471            .as_ref()
1472            .map(|s| s.visible)
1473            .unwrap_or(false);
1474        if settings_visible {
1475            // Dim the editor content behind the settings modal
1476            crate::view::dimming::apply_dimming(frame, size);
1477        }
1478        if let Some(ref mut settings_state) = self.settings_state {
1479            if settings_state.visible {
1480                settings_state.update_focus_states();
1481                let settings_layout = crate::view::settings::render_settings(
1482                    frame,
1483                    size,
1484                    settings_state,
1485                    &*self.theme.read().unwrap(),
1486                );
1487                self.active_chrome_mut().settings_layout = Some(settings_layout);
1488            }
1489        }
1490
1491        // Render calibration wizard if active
1492        if let Some(ref wizard) = self.calibration_wizard {
1493            // Dim the editor content behind the wizard modal
1494            crate::view::dimming::apply_dimming(frame, size);
1495            crate::view::calibration_wizard::render_calibration_wizard(
1496                frame,
1497                size,
1498                wizard,
1499                &*self.theme.read().unwrap(),
1500            );
1501        }
1502
1503        // Render keybinding editor if active
1504        if let Some(ref mut kb_editor) = self.keybinding_editor {
1505            crate::view::dimming::apply_dimming(frame, size);
1506            crate::view::keybinding_editor::render_keybinding_editor(
1507                frame,
1508                size,
1509                kb_editor,
1510                &*self.theme.read().unwrap(),
1511            );
1512        }
1513
1514        // Render event debug dialog if active
1515        if let Some(ref debug) = self.active_window().event_debug {
1516            // Dim the editor content behind the dialog modal
1517            crate::view::dimming::apply_dimming(frame, size);
1518            crate::view::event_debug::render_event_debug(
1519                frame,
1520                size,
1521                debug,
1522                &*self.theme.read().unwrap(),
1523            );
1524        }
1525
1526        // Render the workspace-trust prompt as a blocking modal in the same
1527        // z-band as the settings / wizard modals: dim the whole frame, then
1528        // draw the dialog on top. Placed here (above the generic global-popup
1529        // slot and buffer chrome) so it has strict z-order parity with the
1530        // other modals and can never be obscured by the dashboard/explorer.
1531        let trust_layout = if top_is_trust_modal {
1532            crate::view::dimming::apply_dimming(frame, size);
1533            let selected = self
1534                .global_popups
1535                .top()
1536                .and_then(|p| match &p.content {
1537                    crate::view::popup::PopupContent::List { selected, .. } => Some(*selected),
1538                    _ => None,
1539                })
1540                .unwrap_or(1);
1541            let path = self.working_dir().display().to_string();
1542            let triggers = self.workspace_trust_markers.join(", ");
1543            let secondary_label = if self.workspace_trust_prompt_cancellable {
1544                rust_i18n::t!("trust.dialog.btn_cancel").into_owned()
1545            } else {
1546                let quit_hint = self.keybindings.read().ok().and_then(|kb| {
1547                    kb.get_keybinding_for_action(
1548                        &crate::input::keybindings::Action::Quit,
1549                        crate::input::keybindings::KeyContext::Normal,
1550                    )
1551                });
1552                match quit_hint {
1553                    Some(k) => rust_i18n::t!("trust.dialog.btn_quit_key", key = k).into_owned(),
1554                    None => rust_i18n::t!("trust.dialog.btn_quit").into_owned(),
1555                }
1556            };
1557            Some(
1558                crate::view::workspace_trust_dialog::render_workspace_trust_dialog(
1559                    frame,
1560                    size,
1561                    selected,
1562                    &path,
1563                    &triggers,
1564                    &secondary_label,
1565                    self.workspace_trust_scroll,
1566                    &theme_clone,
1567                ),
1568            )
1569        } else {
1570            None
1571        };
1572        self.active_chrome_mut().workspace_trust_dialog = trust_layout;
1573
1574        if self.active_window_mut().menu_bar_visible {
1575            // Pre-expand DynamicSubmenu items once per registry; without this
1576            // MenuRenderer::render rescans + reparses every theme JSON file
1577            // on every frame.
1578            self.expanded_menus_cache.update(
1579                &self.theme_registry,
1580                &self.menus,
1581                &self.menu_state.themes_dir,
1582            );
1583            let hover_target = self.active_window().mouse_state.hover_target.clone();
1584            let menu_bar_mnemonics = self.config.editor.menu_bar_mnemonics;
1585            let expanded = self.expanded_menus_cache.get().expect("just updated");
1586            let keybindings = self.keybindings.read().unwrap();
1587            let new_menu_layout = crate::view::ui::MenuRenderer::render(
1588                frame,
1589                menu_bar_area,
1590                expanded,
1591                &self.menu_state,
1592                &keybindings,
1593                &*self.theme.read().unwrap(),
1594                hover_target.as_ref(),
1595                menu_bar_mnemonics,
1596            );
1597            drop(keybindings);
1598            self.active_chrome_mut().menu_layout = Some(new_menu_layout);
1599        } else {
1600            self.active_chrome_mut().menu_layout = None;
1601        }
1602
1603        // Render tab context menu if open
1604        let tab_ctx_menu = self.active_window().tab_context_menu.clone();
1605        if let Some(menu) = tab_ctx_menu {
1606            self.render_tab_context_menu(frame, &menu);
1607        }
1608
1609        let fe_ctx_menu = self.active_window().file_explorer_context_menu.clone();
1610        if let Some(menu) = fe_ctx_menu {
1611            self.render_file_explorer_context_menu(frame, &menu);
1612        }
1613
1614        // Record non-editor region theme keys for the theme inspector
1615        self.record_non_editor_theme_regions();
1616
1617        // Render theme info popup (Ctrl+Right-Click)
1618        self.render_theme_info_popup(frame);
1619
1620        // Render tab drag drop zone overlay if dragging a tab
1621        let drag_state_clone = self.active_window().mouse_state.dragging_tab.clone();
1622        if let Some(ref drag_state) = drag_state_clone {
1623            if drag_state.is_dragging() {
1624                self.render_tab_drop_zone(frame, drag_state);
1625            }
1626        }
1627
1628        // Render software mouse cursor when GPM is active
1629        // GPM can't draw its cursor on the alternate screen buffer used by TUI apps,
1630        // so we draw our own cursor at the tracked mouse position.
1631        // This must happen LAST in the render flow so we can read the already-rendered
1632        // cell content and invert it.
1633        if self.active_window_mut().gpm_active {
1634            if let Some((col, row)) = self.active_window_mut().mouse_cursor_position {
1635                use ratatui::style::Modifier;
1636
1637                // Only render if within screen bounds
1638                if col < size.width && row < size.height {
1639                    // Get the cell at this position and add REVERSED modifier to invert colors
1640                    let buf = frame.buffer_mut();
1641                    if let Some(cell) = buf.cell_mut((col, row)) {
1642                        cell.set_style(cell.style().add_modifier(Modifier::REVERSED));
1643                    }
1644                }
1645            }
1646        }
1647
1648        // When keyboard capture mode is active, dim all UI elements outside the terminal
1649        // to visually indicate that focus is exclusively on the terminal
1650        if self.active_window_mut().keyboard_capture && self.active_window().terminal_mode {
1651            // Find the active split's content area
1652            let active_split = self
1653                .windows
1654                .get(&self.active_window)
1655                .and_then(|w| w.buffers.splits())
1656                .map(|(mgr, _)| mgr)
1657                .expect("active window must have a populated split layout")
1658                .active_split();
1659            let active_split_area = self
1660                .active_layout()
1661                .split_areas
1662                .iter()
1663                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
1664                .map(|(_, _, content_rect, _, _, _)| *content_rect);
1665
1666            if let Some(terminal_area) = active_split_area {
1667                self.apply_keyboard_capture_dimming(frame, terminal_area);
1668            }
1669        }
1670
1671        // Commit the active-split hardware cursor (deferred since
1672        // `render_content`) unless a popup has been drawn over that cell.
1673        // Ratatui draws the hardware caret on top of every cell, so a
1674        // popup cannot hide the cursor by painting cells — the only way
1675        // to hide it is to leave `Frame::cursor_position` as `None`, which
1676        // triggers `Terminal::hide_cursor` at the end of the draw.
1677        //
1678        // When a prompt is active the prompt renderer already placed the
1679        // caret on the prompt line via `frame.set_cursor_position`; don't
1680        // override it with the (now-irrelevant) buffer cursor.
1681        if let Some((cx, cy)) = pending_hardware_cursor {
1682            if self.active_window().prompt.is_none() && !self.cursor_obscured_by_overlay(cx, cy) {
1683                frame.set_cursor_position((cx, cy));
1684            }
1685        }
1686
1687        // Convert all colors for terminal capability (256/16 color fallback)
1688        crate::view::color_support::convert_buffer_colors(
1689            frame.buffer_mut(),
1690            self.color_capability,
1691        );
1692
1693        // Frame-buffer animations run last so they mutate the final paint.
1694        self.active_window_mut()
1695            .animations
1696            .apply_all(frame.buffer_mut());
1697
1698        // Floating widget panel is drawn last so it sits above every
1699        // other layer (prompts, popups, animations).
1700        if self.floating_widget_panel.is_some() {
1701            let frame_area = frame.area();
1702            self.render_floating_widget_panel(frame, frame_area);
1703        }
1704    }
1705
1706    /// Compare the hardware cursor's screen position to the previous frame's
1707    /// and, if it moved by more than the "jump" threshold, start a
1708    /// `CursorJump` animation from the old to the new on-screen position.
1709    /// Successive jumps cancel the prior animation so trail effects don't
1710    /// pile up.
1711    ///
1712    /// Cross-split and cross-buffer transitions (focus change, tab switch)
1713    /// are also animated — the trail crosses pane separators on its way
1714    /// from one buffer's cursor cell to another's.
1715    ///
1716    /// The threshold is intentionally generous: arrow-key/typing moves
1717    /// (small `dx`/`dy`) must NOT trigger the animation, but search jumps,
1718    /// goto-line/definition, and pane switches (which always cross several
1719    /// rows or many columns) must.
1720    fn maybe_start_cursor_jump_animation(
1721        &mut self,
1722        current_pos: Option<(u16, u16)>,
1723        active_split: crate::model::event::LeafId,
1724    ) {
1725        // Honour the global animations toggle. Tests default to
1726        // `animations = false` so single-tick `render()` calls observe the
1727        // settled buffer instead of a mid-flight trail; users can also
1728        // disable animations entirely from config. The dedicated
1729        // `cursor_jump_animation` toggle suppresses just the cursor-jump
1730        // trail while leaving ambient animations (tab slides, dashboard,
1731        // plugin effects) running.
1732        if !self.config.editor.animations || !self.config.editor.cursor_jump_animation {
1733            self.previous_cursor_screen_pos = current_pos.map(|p| (p, active_split));
1734            return;
1735        }
1736
1737        let Some(current) = current_pos else {
1738            // Cursor is hidden this frame (e.g. prompt has focus). Reset the
1739            // tracker so the re-emerging cursor doesn't animate from a stale
1740            // spot when focus returns to a buffer.
1741            self.previous_cursor_screen_pos = None;
1742            return;
1743        };
1744
1745        let prev_entry = self.previous_cursor_screen_pos;
1746        // Update tracking unconditionally for the next frame.
1747        self.previous_cursor_screen_pos = Some((current, active_split));
1748
1749        let Some((prev, prev_split)) = prev_entry else {
1750            return;
1751        };
1752        if prev == current && prev_split == active_split {
1753            return;
1754        }
1755
1756        let dx = (current.0 as i32 - prev.0 as i32).abs();
1757        let dy = (current.1 as i32 - prev.1 as i32).abs();
1758        // Animate when the cursor crossed split panes, or when it made a
1759        // non-incremental move within the same pane: more than two rows
1760        // vertically, or — for moves that stay within ±2 rows — at
1761        // least 80 columns horizontally. The horizontal threshold is
1762        // generous because typing, arrow keys, word-jump, and Home/End
1763        // on long source lines can all exceed a smaller bound without
1764        // being a genuine "jump".
1765        let crossed_panes = prev_split != active_split;
1766        let row_jump = dy > 2;
1767        let col_jump = dx >= 80;
1768        if !crossed_panes && !row_jump && !col_jump {
1769            return;
1770        }
1771
1772        // Cancel any prior cursor-jump animation so trails don't stack.
1773        if let Some(prev_anim) = self.cursor_jump_animation.take() {
1774            self.active_window_mut().animations.cancel(prev_anim);
1775        }
1776
1777        let cursor_color = self.theme.read().unwrap().cursor;
1778        let bg_color = self.theme.read().unwrap().editor_bg;
1779        let id = self.active_window_mut().animations.start(
1780            // The bounding box is for runner bookkeeping only — CursorJump
1781            // paints at absolute screen coords and ignores `area`.
1782            ratatui::layout::Rect {
1783                x: prev.0.min(current.0),
1784                y: prev.1.min(current.1),
1785                width: dx as u16 + 1,
1786                height: dy as u16 + 1,
1787            },
1788            crate::view::animation::AnimationKind::CursorJump {
1789                from: prev,
1790                to: current,
1791                duration: std::time::Duration::from_millis(140),
1792                cursor_color,
1793                bg_color,
1794            },
1795        );
1796        self.cursor_jump_animation = Some(id);
1797    }
1798
1799    /// Returns true if `(x, y)` falls inside any popup-style overlay that
1800    /// was rendered this frame. Used to decide whether the hardware cursor
1801    /// should be shown or hidden so it does not bleed through a popup.
1802    fn cursor_obscured_by_overlay(&self, x: u16, y: u16) -> bool {
1803        let inside = |rect: ratatui::layout::Rect| -> bool {
1804            x >= rect.x
1805                && x < rect.x.saturating_add(rect.width)
1806                && y >= rect.y
1807                && y < rect.y.saturating_add(rect.height)
1808        };
1809
1810        if self
1811            .active_chrome()
1812            .popup_areas
1813            .iter()
1814            .any(|entry| inside(entry.1))
1815        {
1816            return true;
1817        }
1818        if self
1819            .active_chrome()
1820            .global_popup_areas
1821            .iter()
1822            .any(|entry| inside(entry.1))
1823        {
1824            return true;
1825        }
1826        if let Some((rect, _, _, _)) = self.active_chrome().suggestions_area {
1827            if inside(rect) {
1828                return true;
1829            }
1830        }
1831        if let Some(ref fb) = self.active_window().file_browser_layout {
1832            if inside(fb.popup_area) {
1833                return true;
1834            }
1835        }
1836        false
1837    }
1838
1839    /// Render the Quick Open hints line showing available mode prefixes
1840    fn render_quick_open_hints(
1841        frame: &mut Frame,
1842        area: ratatui::layout::Rect,
1843        theme: &crate::view::theme::Theme,
1844    ) {
1845        use ratatui::style::{Modifier, Style};
1846        use ratatui::text::{Line, Span};
1847        use ratatui::widgets::Paragraph;
1848        use rust_i18n::t;
1849
1850        let hints_style = Style::default()
1851            .fg(theme.line_number_fg)
1852            .bg(theme.suggestion_selected_bg)
1853            .add_modifier(Modifier::DIM);
1854        let hints_text = t!("quick_open.mode_hints");
1855        // Left-align with small margin
1856        let left_margin = 2;
1857        let hints_width = crate::primitives::display_width::str_width(&hints_text);
1858        let mut spans = Vec::new();
1859        spans.push(Span::styled(" ".repeat(left_margin), hints_style));
1860        spans.push(Span::styled(hints_text.to_string(), hints_style));
1861        let remaining = (area.width as usize).saturating_sub(left_margin + hints_width);
1862        spans.push(Span::styled(" ".repeat(remaining), hints_style));
1863
1864        let paragraph = Paragraph::new(Line::from(spans));
1865        frame.render_widget(paragraph, area);
1866    }
1867
1868    /// Apply dimming effect to UI elements outside the focused terminal area
1869    /// This visually indicates that keyboard capture mode is active
1870    fn apply_keyboard_capture_dimming(
1871        &self,
1872        frame: &mut Frame,
1873        terminal_area: ratatui::layout::Rect,
1874    ) {
1875        let size = frame.area();
1876        crate::view::dimming::apply_dimming_excluding(frame, size, Some(terminal_area));
1877    }
1878
1879    /// Render file browser or suggestions popup as overlay above the prompt line.
1880    /// Called after status bar + prompt so the popup draws on top of both.
1881    fn render_prompt_popups(
1882        &mut self,
1883        frame: &mut Frame,
1884        prompt_area: ratatui::layout::Rect,
1885        width: u16,
1886    ) {
1887        let Some(prompt) = &self.active_window_mut().prompt else {
1888            return;
1889        };
1890
1891        // Overlay prompts (Live Grep, issue #1796) get a dedicated
1892        // centred floating frame instead of the bottom-anchored popup.
1893        if prompt.overlay {
1894            let frame_area = frame.area();
1895            self.render_overlay_prompt(frame, frame_area);
1896            return;
1897        }
1898
1899        if matches!(
1900            prompt.prompt_type,
1901            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
1902        ) {
1903            let hover_target = self.active_window().mouse_state.hover_target.clone();
1904            let theme = self.theme.read().unwrap().clone();
1905            let keybindings = self.keybindings.read().unwrap();
1906            let kb_clone = keybindings.clone();
1907            drop(keybindings);
1908            let max_height = prompt_area.y.saturating_sub(1).min(20);
1909            let popup_area = ratatui::layout::Rect {
1910                x: 0,
1911                y: prompt_area.y.saturating_sub(max_height),
1912                width,
1913                height: max_height,
1914            };
1915            let __win = self.active_window_mut();
1916            let Some(file_open_state) = &mut __win.file_open_state else {
1917                return;
1918            };
1919            __win.file_browser_layout = crate::view::ui::FileBrowserRenderer::render(
1920                frame,
1921                popup_area,
1922                file_open_state,
1923                &theme,
1924                &hover_target,
1925                Some(&kb_clone),
1926            );
1927            return;
1928        }
1929
1930        if prompt.suggestions.is_empty() {
1931            return;
1932        }
1933
1934        let suggestion_count = prompt.suggestions.len().min(10);
1935        let is_quick_open = prompt.prompt_type == crate::view::prompt::PromptType::QuickOpen;
1936        let hints_height: u16 = if is_quick_open { 1 } else { 0 };
1937        let height = suggestion_count as u16 + 2 + hints_height;
1938
1939        let suggestions_area = ratatui::layout::Rect {
1940            x: 0,
1941            y: prompt_area.y.saturating_sub(height),
1942            width,
1943            height: height - hints_height,
1944        };
1945
1946        frame.render_widget(ratatui::widgets::Clear, suggestions_area);
1947
1948        // Adjust the prompt's scroll position to keep the selected item
1949        // visible, scrolling the minimum amount required.
1950        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
1951            prompt.ensure_selected_visible();
1952        }
1953        let Some(prompt) = &self.active_window().prompt else {
1954            return;
1955        };
1956
1957        let new_suggestions_area = SuggestionsRenderer::render_with_hover(
1958            frame,
1959            suggestions_area,
1960            prompt,
1961            &*self.theme.read().unwrap(),
1962            self.active_window().mouse_state.hover_target.as_ref(),
1963            true,
1964        );
1965        let chrome = self.active_chrome_mut();
1966        chrome.suggestions_area = new_suggestions_area;
1967        if chrome.suggestions_area.is_some() {
1968            chrome.suggestions_outer_area = Some(suggestions_area);
1969        }
1970
1971        if is_quick_open {
1972            let hints_area = ratatui::layout::Rect {
1973                x: 0,
1974                y: prompt_area.y.saturating_sub(hints_height),
1975                width,
1976                height: hints_height,
1977            };
1978            frame.render_widget(ratatui::widgets::Clear, hints_area);
1979            Self::render_quick_open_hints(frame, hints_area, &*self.theme.read().unwrap());
1980        }
1981    }
1982
1983    /// Resolve the overlay's currently-selected match into a real
1984    /// `Buffer` parked in a phantom `LeafId`, so the preview pane can
1985    /// reuse the regular per-leaf renderer (with syntax highlighting,
1986    /// gutter, scrollbars, folding). No-op when the prompt has no
1987    /// selection or its label is not a `path:line[:col]` triple.
1988    /// Render the entire stashed split tree of `self.preview_window_id`
1989    /// into `inner` — Primitive #1 of
1990    /// `docs/internal/orchestrator-sessions-design.md`'s "Rich
1991    /// Control Room rendering". Reuses the editor's existing
1992    /// `render_content` path against the previewed session's
1993    /// stashed `(SplitManager, view_states)` so syntax
1994    /// highlighting, terminal grids, decorations, and folding
1995    /// all surface natively in the preview pane.
1996    ///
1997    /// The previewed session's splits stash is `take`n out for
1998    /// the duration of the call (so we can pass `&mut` through
1999    /// the renderer without re-entering `self.windows`) and put
2000    /// back after. `pending_hardware_cursor` and
2001    /// `cell_theme_map` use scratch locals so the active editor
2002    /// area's hit-testing isn't clobbered by the preview pass.
2003    fn render_session_preview_into_rect(
2004        &mut self,
2005        frame: &mut ratatui::Frame,
2006        inner: ratatui::layout::Rect,
2007        theme: &crate::view::theme::Theme,
2008    ) {
2009        let Some(sid) = self.preview_window_id else {
2010            return;
2011        };
2012
2013        // Lazy materialization: a previewed session whose workspace
2014        // hasn't been restored yet gets restored on its first preview
2015        // frame, so the embed paints real content. No-op once
2016        // materialized (cleared from `materialize_pending`).
2017        self.materialize_window(sid);
2018
2019        // Terminal grid → buffer text "sync" was previously a
2020        // multi-step append/reload/truncate dance that mutated the
2021        // backing file on every preview-render frame just to make
2022        // the live screen visible inside the embed. That worked
2023        // around `render_terminal_splits` being hard-coded to the
2024        // active window's `terminal_buffers` map — during preview
2025        // the active window is the *caller's* session, so the
2026        // overlay couldn't find the previewed terminal.
2027        //
2028        // `render_terminal_splits` is now an `impl Window` method,
2029        // so the preview path can ask the previewed window
2030        // directly. The overlay paints the live PTY grid (with
2031        // colors, attributes, no cursor) on top of `SplitRenderer`'s
2032        // text rendering for every terminal buffer in the embed —
2033        // no file mutation, no reload, no truncate. The buffer's
2034        // backing file stays untouched between frames.
2035
2036        // Pull the previewed window's split stash and sub-fields
2037        // out under one `&mut Window` borrow. Multiple disjoint
2038        // sub-borrows (`buffers`, `event_logs`, `splits`) coexist
2039        // on the same `Window`, so the renderer call can take all
2040        // three by `&mut` while the rest of `&mut self` stays
2041        // available for `composite_buffers` / `config` / etc.
2042        //
2043        // Step 0h: previously this used `splits.take()` + restore
2044        // because the inline-borrow patterns elsewhere couldn't
2045        // co-exist with a held `&mut sid.splits`. Now that all
2046        // per-window state lives on `Window`, we destructure
2047        // `splits.as_mut()` directly — no transient swap, no
2048        // side-effect plumbing — matching design Primitive #1.
2049        // Bail if the session has no stash yet (never been
2050        // activated and never had a terminal / file routed in via
2051        // createTerminal({windowId})), or has been closed under us
2052        // — e.g. an Orchestrator Archive / Delete completes between
2053        // the floating panel's spec being rebuilt and the next
2054        // render, so the embed's `windowId` momentarily points to
2055        // a window the host already removed. Early-return rather
2056        // than panic; the next plugin refresh re-emits the spec
2057        // without the dead embed.
2058        let Some(__win_for_preview) = self.windows.get_mut(&sid) else {
2059            return;
2060        };
2061        let __preview_metadata = &__win_for_preview.buffer_metadata;
2062        let __preview_event_logs = &mut __win_for_preview.event_logs;
2063        let __preview_composite_buffers = &mut __win_for_preview.composite_buffers;
2064        let __preview_composite_view_states = &mut __win_for_preview.composite_view_states;
2065        // Issue #2035: pass the previewed window's actual
2066        // `grouped_subtrees` map. The previous code allocated an
2067        // empty HashMap here, which made the split renderer unable
2068        // to resolve any `active_group_tab` to its panel layout —
2069        // so a session whose active tab was a buffer group (e.g.
2070        // git_log's log/detail panels) silently fell through to
2071        // rendering the split's underlying pre-group buffer.
2072        let __preview_grouped_subtrees = &__win_for_preview.grouped_subtrees;
2073        let preview_tab_bar_visible = __win_for_preview.tab_bar_visible;
2074
2075        // Per-call scratch — keeps the preview pass from
2076        // clobbering the active editor area's hit-testing /
2077        // hardware-cursor placement.
2078        let mut scratch_cell_theme_map: Vec<crate::app::types::CellThemeInfo> = Vec::new();
2079        let mut scratch_pending_cursor: Option<(u16, u16)> = None;
2080        let lsp_waiting = false; // preview never shows LSP-waiting chrome
2081
2082        let mut preview_split_areas: Vec<(
2083            crate::model::event::LeafId,
2084            fresh_core::BufferId,
2085            ratatui::layout::Rect,
2086            ratatui::layout::Rect,
2087            usize,
2088            usize,
2089        )> = Vec::new();
2090        __win_for_preview
2091            .buffers
2092            .with_all_mut(|preview_buffers, mgr, view_states| {
2093                let result = crate::view::ui::SplitRenderer::render_content(
2094                    frame,
2095                    inner,
2096                    &*mgr,
2097                    preview_buffers,
2098                    __preview_metadata,
2099                    __preview_event_logs,
2100                    __preview_composite_buffers,
2101                    __preview_composite_view_states,
2102                    theme,
2103                    self.ansi_background.as_ref(),
2104                    self.background_fade,
2105                    lsp_waiting,
2106                    self.config.editor.large_file_threshold_bytes,
2107                    self.config.editor.line_wrap,
2108                    self.config.editor.estimated_line_length,
2109                    self.config.editor.highlight_context_bytes,
2110                    Some(view_states),
2111                    __preview_grouped_subtrees,
2112                    true, // hide_cursor — the active session owns the hardware caret
2113                    None, // no tab-hover routing in the preview
2114                    None,
2115                    None,
2116                    false, // not maximized
2117                    self.config.editor.relative_line_numbers,
2118                    preview_tab_bar_visible,
2119                    self.config.editor.use_terminal_bg,
2120                    self.session_mode || !self.software_cursor_only,
2121                    self.software_cursor_only,
2122                    // Scrollbars are noisy in a small preview rect; the
2123                    // active session's chrome is the source of truth.
2124                    false,
2125                    false,
2126                    self.config.editor.diagnostics_inline_text,
2127                    false, // hide tilde markers in the preview
2128                    self.config.editor.highlight_current_column,
2129                    &mut scratch_cell_theme_map,
2130                    inner.width,
2131                    &mut scratch_pending_cursor,
2132                );
2133                preview_split_areas = result.0;
2134            });
2135
2136        // Resize the previewed window's terminal PTYs to fit the
2137        // preview embed before painting their grids. Without this,
2138        // the PTY child (e.g. `top`, `htop`, `vim`, claude) keeps
2139        // drawing at the dimensions it had when last active — often
2140        // the full terminal height — so the preview embed only
2141        // shows the top slice of a much taller frame. Resizing
2142        // SIGWINCHes the PTY, which redraws at the new size, and
2143        // the next render frame paints the correctly-sized grid.
2144        // When the user dives into the session,
2145        // `Window::resize_visible_terminals` will resize back up to
2146        // the dive view's split rect.
2147        if let Some(win) = self.windows.get_mut(&sid) {
2148            for (_split_id, buffer_id, content_rect, _scrollbar_rect, _, _) in &preview_split_areas
2149            {
2150                if win.terminal_buffers.contains_key(buffer_id)
2151                    && content_rect.width > 0
2152                    && content_rect.height > 0
2153                {
2154                    win.resize_terminal(*buffer_id, content_rect.width, content_rect.height);
2155                }
2156            }
2157        }
2158
2159        // Overlay live PTY grids for terminal buffers in the
2160        // previewed window's splits — paints colors, attributes,
2161        // and the visible screen on top of `SplitRenderer`'s text
2162        // rendering. `cursor_visible_if_active = false` keeps the
2163        // preview read-only: no blinking cursor over a session
2164        // the user isn't currently driving.
2165        if let Some(win) = self.windows.get(&sid) {
2166            win.render_terminal_splits(frame, &preview_split_areas, false);
2167        }
2168    }
2169
2170    fn prepare_overlay_preview(&mut self) {
2171        use crate::input::quick_open::parse_path_line_col;
2172
2173        let parsed = {
2174            self.active_window()
2175                .prompt
2176                .as_ref()
2177                .and_then(|prompt| {
2178                    let idx = prompt.selected_suggestion?;
2179                    prompt.suggestions.get(idx)
2180                })
2181                .map(|s| {
2182                    // `value` is the authoritative `path:line:col` for the
2183                    // result. We must not rely on parsing the user-facing
2184                    // label (`text`), which may carry source badges (e.g.
2185                    // "[term]") that make it unparseable as a path. Only fall
2186                    // back to the label when `value` is absent/unparseable.
2187                    if let Some(v) = s.value.as_deref() {
2188                        let from_value = parse_path_line_col(v);
2189                        if !from_value.0.is_empty() && from_value.1.is_some() {
2190                            return from_value;
2191                        }
2192                    }
2193                    parse_path_line_col(&s.text)
2194                })
2195        };
2196        // No selectable result (empty list, no selection, or an
2197        // unparseable entry): blank the preview so the previous match's
2198        // content doesn't linger after the result list clears.
2199        let (path_str, line, col) = match parsed {
2200            Some((path, line, col)) if !path.is_empty() => (path, line, col),
2201            _ => {
2202                self.blank_overlay_preview();
2203                return;
2204            }
2205        };
2206        let line = line.unwrap_or(1).saturating_sub(1);
2207        let col = col.unwrap_or(1).saturating_sub(1);
2208
2209        // Resolve relative to the working directory.
2210        let path_buf = std::path::PathBuf::from(&path_str);
2211        let abs_path = if path_buf.is_absolute() {
2212            path_buf
2213        } else {
2214            self.working_dir().join(&path_buf)
2215        };
2216        // Canonicalize for buffer-dedup parity with open_file_no_focus.
2217        let abs_path = self
2218            .authority
2219            .filesystem
2220            .canonicalize(&abs_path)
2221            .unwrap_or(abs_path);
2222
2223        // If the standalone state already targets this path, just
2224        // re-seed the cursor and skip the file-load roundtrip.
2225        let already_target = self
2226            .active_window()
2227            .overlay_preview_state
2228            .as_ref()
2229            .is_some_and(|st| {
2230                self.windows
2231                    .get(&self.active_window)
2232                    .map(|w| &w.buffers)
2233                    .expect("active window present")
2234                    .get(&st.buffer_id)
2235                    .and_then(|s| s.buffer.file_path())
2236                    .is_some_and(|p| p == abs_path.as_path())
2237            });
2238
2239        let buffer_id = if already_target {
2240            self.active_window_mut()
2241                .overlay_preview_state
2242                .as_ref()
2243                .unwrap()
2244                .buffer_id
2245        } else {
2246            // Snapshot whether this path was already known so we can
2247            // tell "I just loaded it for preview" from "the user had
2248            // it open" — only the former gets cleaned up on close.
2249            let was_open = self
2250                .buffers()
2251                .iter()
2252                .any(|(_, s)| s.buffer.file_path() == Some(abs_path.as_path()));
2253            // Capture the active split so we can undo the side
2254            // effects of `open_file_no_focus` (it adds the buffer to
2255            // the active split's tabs and may switch its active
2256            // buffer to the loaded file).
2257            let source_split = self
2258                .windows
2259                .get(&self.active_window)
2260                .and_then(|w| w.buffers.splits())
2261                .map(|(mgr, _)| mgr)
2262                .expect("active window must have a populated split layout")
2263                .active_split();
2264            // `open_file_for_preview` always allocates a fresh buffer
2265            // — never repurposes the "no name" empty buffer the user
2266            // is currently looking at — so the background view stays
2267            // intact while we cycle through preview results.
2268            let buffer_id = match self.open_file_for_preview(abs_path.as_path()) {
2269                Ok(id) => id,
2270                Err(_e) => return,
2271            };
2272            if !was_open {
2273                if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
2274                    meta.hidden_from_tabs = true;
2275                }
2276                // Drop the buffer from every split's `open_buffers`
2277                // list so it doesn't surface as a tab anywhere. The
2278                // phantom buffer is rendered exclusively via the
2279                // overlay's standalone view-state — it doesn't need
2280                // to be in `open_buffers`.
2281                let leaf_ids: Vec<_> = self
2282                    .windows
2283                    .get(&self.active_window)
2284                    .and_then(|w| w.buffers.splits())
2285                    .map(|(_, vs)| vs)
2286                    .expect("active window must have a populated split layout")
2287                    .keys()
2288                    .copied()
2289                    .collect();
2290                for leaf_id in leaf_ids {
2291                    if let Some(view_state) = self
2292                        .windows
2293                        .get_mut(&self.active_window)
2294                        .and_then(|w| w.split_view_states_mut())
2295                        .expect("active window must have a populated split layout")
2296                        .get_mut(&leaf_id)
2297                    {
2298                        view_state.remove_buffer(buffer_id);
2299                    }
2300                }
2301                // open_file_no_focus may have switched the active
2302                // buffer of the source split. Restore it.
2303                let preview_loaded: std::collections::HashSet<BufferId> = self
2304                    .active_window_mut()
2305                    .overlay_preview_state
2306                    .as_ref()
2307                    .map(|st| st.loaded_buffers.clone())
2308                    .unwrap_or_default();
2309                let __active_id = self.active_window;
2310                let __win = self
2311                    .windows
2312                    .get_mut(&__active_id)
2313                    .expect("active window must exist");
2314                let __buffer_keys: Vec<BufferId> = __win.buffers.ids();
2315                let (__mgr, __vs_map) = __win
2316                    .buffers
2317                    .splits_mut()
2318                    .expect("active window must have a populated split layout");
2319                if let Some(source_state) = __vs_map.get_mut(&source_split) {
2320                    if source_state.active_buffer == buffer_id {
2321                        let fallback = source_state
2322                            .open_buffers
2323                            .iter()
2324                            .find_map(|t| t.as_buffer())
2325                            .or_else(|| {
2326                                __buffer_keys
2327                                    .iter()
2328                                    .copied()
2329                                    .find(|b| *b != buffer_id && !preview_loaded.contains(b))
2330                            });
2331                        if let Some(fb) = fallback {
2332                            source_state.switch_buffer(fb);
2333                            __mgr.set_split_buffer(source_split, fb);
2334                        }
2335                    }
2336                }
2337                self.windows
2338                    .get_mut(&self.active_window)
2339                    .and_then(|w| w.split_manager_mut())
2340                    .expect("active window must have a populated split layout")
2341                    .set_active_split(source_split);
2342            }
2343            buffer_id
2344        };
2345
2346        // The buffer (if any) the preview pointed at on the previous
2347        // frame. When the selection moves to a result in a *different*
2348        // file we must drop our search-match overlays from the old
2349        // buffer (see the highlight refresh below).
2350        let prev_preview_buffer = self
2351            .active_window()
2352            .overlay_preview_state
2353            .as_ref()
2354            .map(|s| s.buffer_id);
2355
2356        // Build (or update) the standalone preview state. Held off
2357        // `split_view_states` so cross-cutting iteration never touches
2358        // it.
2359        let need_init = self.active_window_mut().overlay_preview_state.is_none();
2360        if need_init {
2361            let mut view_state = crate::view::split::SplitViewState::with_buffer(
2362                self.terminal_width,
2363                self.terminal_height,
2364                buffer_id,
2365            );
2366            view_state.apply_config_defaults(
2367                self.config.editor.line_numbers,
2368                self.config.editor.highlight_current_line,
2369                self.active_window().resolve_line_wrap_for_buffer(buffer_id),
2370                self.config.editor.wrap_indent,
2371                self.active_window()
2372                    .resolve_wrap_column_for_buffer(buffer_id),
2373                self.config.editor.rulers.clone(),
2374            );
2375            let mut loaded_buffers = std::collections::HashSet::new();
2376            // Whether this *first* preview buffer was newly loaded.
2377            // The pre-existing case skips the `was_open` branch so
2378            // we re-derive it from buffer_metadata: a buffer with
2379            // hidden_from_tabs=true that we just touched is one we
2380            // owned. Simpler: track via the existing-target check:
2381            // if `already_target` was false above, the buffer was
2382            // either pre-open (we left meta alone) or freshly
2383            // loaded (we set hidden_from_tabs=true). Re-check.
2384            if let Some(meta) = self.active_window().buffer_metadata.get(&buffer_id) {
2385                if meta.hidden_from_tabs {
2386                    loaded_buffers.insert(buffer_id);
2387                }
2388            }
2389            self.active_window_mut().overlay_preview_state =
2390                Some(crate::app::types::OverlayPreviewState {
2391                    buffer_id,
2392                    view_state,
2393                    loaded_buffers,
2394                    blanked: false,
2395                    centered_byte: None,
2396                });
2397        } else {
2398            // Pre-compute hidden flag (immutable borrow on self.windows)
2399            // before taking the mutable borrow on overlay_preview_state.
2400            let hidden_from_tabs = self
2401                .windows
2402                .get(&self.active_window)
2403                .and_then(|w| w.buffer_metadata.get(&buffer_id))
2404                .is_some_and(|meta| meta.hidden_from_tabs);
2405            if let Some(state) = self.active_window_mut().overlay_preview_state.as_mut() {
2406                if state.buffer_id != buffer_id {
2407                    state.view_state.switch_buffer(buffer_id);
2408                    // Keep the struct's `buffer_id` in lockstep with the
2409                    // view-state's active buffer: the renderer looks up the
2410                    // buffer to draw via this field, so a stale value here
2411                    // renders the *previous* file's text at the new file's
2412                    // scroll offset (wrong content, or blank past EOF).
2413                    state.buffer_id = buffer_id;
2414                    // New file in the preview ⇒ force a recenter below.
2415                    state.centered_byte = None;
2416                    if hidden_from_tabs {
2417                        state.loaded_buffers.insert(buffer_id);
2418                    }
2419                }
2420            }
2421        }
2422
2423        // Set the cursor to the match position and centre it vertically.
2424        let byte_offset = self
2425            .buffers()
2426            .get(&buffer_id)
2427            .map(|s| {
2428                s.buffer
2429                    .position_to_offset(crate::model::piece_tree::Position { line, column: col })
2430            })
2431            .unwrap_or(0);
2432
2433        // The overlay preview is used exclusively by the Live Grep
2434        // floating overlay, so the prompt input IS the search query.
2435        // Highlight every occurrence in the visible region — previously
2436        // the match was only reachable via the (hidden) cursor, which is
2437        // near-invisible against the preview chrome. Capture the query and
2438        // theme colours before the window borrow below.
2439        let query = self
2440            .active_window()
2441            .prompt
2442            .as_ref()
2443            .map(|p| p.input.clone())
2444            .unwrap_or_default();
2445        let (search_fg, search_bg) = {
2446            let theme = self.theme.read().unwrap();
2447            (theme.search_match_fg, theme.search_match_bg)
2448        };
2449        // Live Grep defaults to regex with smart-case (case-insensitive
2450        // unless the query carries an uppercase letter) — mirror that so
2451        // the highlight tracks what the search actually matched. A query
2452        // that isn't valid regex falls back to a literal match.
2453        let preview_regex = if query.is_empty() {
2454            None
2455        } else {
2456            let case_insensitive = !query.chars().any(|c| c.is_uppercase());
2457            regex::RegexBuilder::new(&query)
2458                .case_insensitive(case_insensitive)
2459                .build()
2460                .or_else(|_| {
2461                    regex::RegexBuilder::new(&regex::escape(&query))
2462                        .case_insensitive(case_insensitive)
2463                        .build()
2464                })
2465                .ok()
2466        };
2467        let preview_ns = crate::view::overlay::OverlayNamespace::from_string(
2468            "overlay-preview-search".to_string(),
2469        );
2470
2471        let active_id = self.active_window;
2472        if let Some(win) = self.windows.get_mut(&active_id) {
2473            // `buffers` and `overlay_preview_state` are distinct fields, so
2474            // these mutable borrows are disjoint.
2475            let preview_buffer = win.buffers.get_mut(&buffer_id);
2476            let preview_state = win.overlay_preview_state.as_mut();
2477            if let (Some(state), Some(pstate)) = (preview_buffer, preview_state) {
2478                pstate.view_state.cursors.primary_mut().position = byte_offset;
2479                // Force line wrapping on for the preview regardless of the
2480                // global `editor.line_wrap` setting (and of a switched-in
2481                // buffer's fresh default): the preview pane has no
2482                // horizontal scroll affordance, so without wrapping a match
2483                // deep in a long line scrolls off-screen. Wrapping moots
2484                // horizontal scroll, so reset it to the left edge.
2485                // `view_state` derefs to the active buffer's
2486                // `BufferViewState`, so this targets the rendered buffer.
2487                pstate.view_state.viewport.line_wrap_enabled = true;
2488                // Recentre only when the selected match changed (issue
2489                // #2119) so a mouse-wheel scroll of the preview is
2490                // preserved; `center_on_position` counts real visual rows so
2491                // a match deep in a wrapped doc still lands mid-pane.
2492                if pstate.centered_byte != Some(byte_offset) {
2493                    pstate.view_state.viewport.left_column = 0;
2494                    pstate.view_state.viewport.horizontal_scroll_offset = 0;
2495                    pstate
2496                        .view_state
2497                        .viewport
2498                        .center_on_position(&mut state.buffer, byte_offset);
2499                    pstate.centered_byte = Some(byte_offset);
2500                }
2501                // We have a live target: ensure the pane is shown.
2502                pstate.blanked = false;
2503
2504                // Rebuild the search-match overlays for the now-visible
2505                // region. Cleared + re-added every frame (cheap; bounded
2506                // to the viewport) so they track scrolling and edits, the
2507                // same contract `Window::update_search_highlights` uses.
2508                state
2509                    .overlays
2510                    .clear_namespace(&preview_ns, &mut state.marker_list);
2511                if let Some(re) = &preview_regex {
2512                    let visible_start = pstate.view_state.viewport.top_byte;
2513                    let visible_rows = pstate.view_state.viewport.height as usize;
2514                    let mut visible_end = visible_start;
2515                    {
2516                        let mut iter = state.buffer.line_iterator(visible_start, 80);
2517                        for _ in 0..visible_rows {
2518                            if let Some((line_start, line_content)) = iter.next_line() {
2519                                visible_end = line_start + line_content.len();
2520                            } else {
2521                                break;
2522                            }
2523                        }
2524                    }
2525                    visible_end = visible_end.min(state.buffer.len());
2526                    let visible_text = state.get_text_range(visible_start, visible_end);
2527                    for mat in re.find_iter(&visible_text) {
2528                        if mat.start() == mat.end() {
2529                            continue;
2530                        }
2531                        let absolute_pos = visible_start + mat.start();
2532                        let match_len = mat.end() - mat.start();
2533                        let style = ratatui::style::Style::default().fg(search_fg).bg(search_bg);
2534                        let overlay = crate::view::overlay::Overlay::with_namespace(
2535                            &mut state.marker_list,
2536                            absolute_pos..(absolute_pos + match_len),
2537                            crate::view::overlay::OverlayFace::Style { style },
2538                            preview_ns.clone(),
2539                        )
2540                        .with_priority_value(10);
2541                        state.overlays.add(overlay);
2542                    }
2543                }
2544            }
2545
2546            // The selection jumped to a result in a different file: scrub
2547            // our overlays from the previously-previewed buffer. Matters
2548            // only for buffers the user already had open — preview-loaded
2549            // buffers are closed wholesale on overlay teardown.
2550            if let Some(prev) = prev_preview_buffer {
2551                if prev != buffer_id {
2552                    if let Some(prev_state) = win.buffers.get_mut(&prev) {
2553                        prev_state
2554                            .overlays
2555                            .clear_namespace(&preview_ns, &mut prev_state.marker_list);
2556                    }
2557                }
2558            }
2559        }
2560    }
2561
2562    /// Blank the Live Grep preview pane: it renders just its frame until
2563    /// the next selectable result. Keeps `overlay_preview_state` (and its
2564    /// `loaded_buffers` cleanup tracking) intact.
2565    fn blank_overlay_preview(&mut self) {
2566        if let Some(state) = self.active_window_mut().overlay_preview_state.as_mut() {
2567            state.blanked = true;
2568        }
2569    }
2570
2571    /// Render the active prompt as a centred floating overlay
2572    /// (issue #1796). Layout, top-down inside the overlay frame:
2573    ///
2574    /// ```text
2575    /// ┌─ Live Grep ──────────────────────────────────[Esc to close]┐
2576    /// │ Search: split_active|                           12 / 142    │  ← input row
2577    /// │ ─────────────────────────────────────────────────────────── │
2578    /// │  src/view/split.rs:1117  pub fn split_active(    │ preview │  ← results
2579    /// │  src/view/split.rs:1123  self.split_active_pos…  │  pane   │     (+ optional
2580    /// │ …                                                │         │      preview)
2581    /// └────────────────────────────────────────────────────────────┘
2582    /// ```
2583    ///
2584    /// The overlay does *not* mutate the split tree; it is a pure
2585    /// `ratatui` overdraw, so dismissing leaves the user's underlying
2586    /// layout exactly as it was (the issue-#1796 acceptance test).
2587    fn render_overlay_prompt(&mut self, frame: &mut Frame, area: ratatui::layout::Rect) {
2588        use ratatui::layout::Rect;
2589        use ratatui::style::{Modifier, Style};
2590        use ratatui::text::{Line, Span};
2591        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2592
2593        // Compute the overlay rect via the same percentage logic the
2594        // popup engine uses. 90% × 90% of the terminal, centred.
2595        let overlay_rect = Self::centered_overlay_rect(area, 90, 90);
2596
2597        // Snapshot view-relevant state before any mutable borrows.
2598        let theme = self.theme.read().unwrap().clone();
2599        // The suggestion list inside the overlay can be ~30 rows
2600        // tall on a typical terminal. Pass the *actual* visible
2601        // count to `ensure_selected_visible_within` so the scroll
2602        // offset only advances when the selection genuinely passes
2603        // the bottom of the visible window — not when it crosses
2604        // the bottom-popup default cap of `MAX_VISIBLE_SUGGESTIONS`
2605        // (= 10), which would scroll prematurely.
2606        //
2607        // Geometry: overlay frame border (2) + input row (1) +
2608        // optional toolbar row (1, when `prompt.title` is non-empty)
2609        // + separator (1). The suggestions popup is rendered
2610        // borderless inside the overlay (the outer frame already
2611        // provides a border, so adding a nested one creates a
2612        // double-frame). Inner content height = overlay.height -
2613        // chrome.
2614        // Toolbar height must be the *actual* rendered row count — a widget
2615        // toolbar is ≥2 rows (e.g. "Search in:" + "Match:") and wraps to more
2616        // on a narrow terminal. Measuring it (vs assuming 1) keeps
2617        // `suggestions_visible_rows` honest, so `ensure_selected_visible`
2618        // doesn't let the selection scroll just past the real list bottom.
2619        let inner_w = overlay_rect.width.saturating_sub(2);
2620        let toolbar_rows: usize = self
2621            .active_window()
2622            .prompt
2623            .as_ref()
2624            .map(|p| {
2625                if let Some(spec) = p.toolbar_widget.as_ref() {
2626                    crate::widgets::render_spec_no_autofocus(
2627                        spec,
2628                        &std::collections::HashMap::new(),
2629                        p.toolbar_focus.as_deref().unwrap_or(""),
2630                        inner_w as u32,
2631                    )
2632                    .entries
2633                    .len()
2634                } else if p.title.is_empty() {
2635                    0
2636                } else {
2637                    1
2638                }
2639            })
2640            .unwrap_or(0);
2641        let footer_visible = self
2642            .active_window()
2643            .prompt
2644            .as_ref()
2645            .map(|p| !p.footer.is_empty())
2646            .unwrap_or(false);
2647        // Chrome around the result list: frame border (2) + input (1) +
2648        // separator (1) + toolbar (`toolbar_rows`) + optional full-width footer (1).
2649        let chrome_rows: usize = 4 + toolbar_rows + usize::from(footer_visible);
2650        let suggestions_visible_rows = (overlay_rect.height as usize).saturating_sub(chrome_rows);
2651        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
2652            // Skip when the user has wheel-scrolled the list — keeping the
2653            // selection pinned in view would undo their scroll (issue #2119).
2654            if !prompt.manual_scroll {
2655                prompt.ensure_selected_visible_within(suggestions_visible_rows);
2656            }
2657        }
2658        let Some(prompt) = self.active_window().prompt.as_ref() else {
2659            return;
2660        };
2661        let prompt = prompt.clone();
2662
2663        // Dim everything outside the overlay rect so the user's
2664        // focus visibly belongs to the popup. Reuses the same RGB-
2665        // darkening pass the Settings modal uses (`view::dimming`)
2666        // — Modifier::DIM alone is barely visible on most terminals.
2667        crate::view::dimming::apply_dimming_excluding(frame, frame.area(), Some(overlay_rect));
2668
2669        // Clear and frame. Plugin-owned prompts can publish their
2670        // own title via `editor.setPromptTitle(...)`; falls back to
2671        // " Live Grep " plus shortcut hints when unset (so a
2672        // Resume-replay prompt and freshly-opened plugin prompt look
2673        // similar even though they take different code paths).
2674        frame.render_widget(Clear, overlay_rect);
2675        let default_title: Vec<fresh_core::api::StyledText> = {
2676            // Mirrors `updateOverlayTitle` in live_grep.ts (kept in
2677            // sync deliberately so a Resume-replay overlay and a
2678            // freshly-opened plugin overlay look identical). The
2679            // input row's prefix already says "Live grep:", so the
2680            // frame title doesn't repeat the feature name — it
2681            // shows shortcut hints only. `resume_live_grep` is
2682            // intentionally NOT shown here; that shortcut only
2683            // matters once the overlay is closed.
2684            use crate::input::keybindings::KeyContext;
2685            use fresh_core::api::{OverlayColorSpec, OverlayOptions, StyledText};
2686            let keybindings = self.keybindings.read().unwrap();
2687            let mut hints: Vec<(String, &str)> = Vec::new();
2688            if let Some(k) = keybindings
2689                .find_keybinding_for_action("cycle_live_grep_provider", KeyContext::Prompt)
2690            {
2691                hints.push((k, "switch grep provider"));
2692            }
2693            if let Some(k) = keybindings
2694                .find_keybinding_for_action("live_grep_export_quickfix", KeyContext::Prompt)
2695            {
2696                hints.push((k, "save matches"));
2697            }
2698            if hints.is_empty() {
2699                Vec::new()
2700            } else {
2701                let hint_style = Some(OverlayOptions {
2702                    fg: Some(OverlayColorSpec::ThemeKey("ui.help_key_fg".into())),
2703                    ..OverlayOptions::default()
2704                });
2705                let sep_style = Some(OverlayOptions {
2706                    fg: Some(OverlayColorSpec::ThemeKey("ui.popup_border_fg".into())),
2707                    ..OverlayOptions::default()
2708                });
2709                let mut segs: Vec<StyledText> = Vec::new();
2710                for (i, (k, verb)) in hints.into_iter().enumerate() {
2711                    if i > 0 {
2712                        segs.push(StyledText {
2713                            text: " · ".into(),
2714                            style: sep_style.clone(),
2715                        });
2716                    }
2717                    segs.push(StyledText {
2718                        text: k,
2719                        style: hint_style.clone(),
2720                    });
2721                    segs.push(StyledText {
2722                        text: format!(" {verb}"),
2723                        style: None,
2724                    });
2725                }
2726                segs
2727            }
2728        };
2729        let title_segs: &[fresh_core::api::StyledText] = if prompt.title.is_empty() {
2730            &default_title
2731        } else {
2732            &prompt.title
2733        };
2734        let normal_title_style = Style::default()
2735            .fg(theme.prompt_fg)
2736            .add_modifier(Modifier::BOLD);
2737        let title_spans: Vec<Span> = title_segs
2738            .iter()
2739            .map(|seg| {
2740                let style = match &seg.style {
2741                    Some(opts) => Self::resolve_overlay_style(opts, &theme),
2742                    None => normal_title_style,
2743                };
2744                Span::styled(seg.text.clone(), style)
2745            })
2746            .collect();
2747        let block = Block::default()
2748            .borders(Borders::ALL)
2749            .border_style(Style::default().fg(theme.popup_border_fg))
2750            .style(Style::default().bg(theme.suggestion_bg));
2751        let inner = block.inner(overlay_rect);
2752        frame.render_widget(block, overlay_rect);
2753
2754        if inner.height == 0 || inner.width == 0 {
2755            return;
2756        }
2757
2758        // If the plugin supplied a widget toolbar, render it now (full inner
2759        // width) so we know its height before laying out the header band. The
2760        // toggles are real `Toggle` widgets — themed and clickable — rather
2761        // than styled text. `render_spec` is stateless here (empty prior
2762        // state / no focus key): a `Toggle`'s checked-ness lives in the spec,
2763        // and click-to-toggle is routed by key (no registry needed).
2764        let toolbar_focus_key = prompt.toolbar_focus.as_deref().unwrap_or("");
2765        let toolbar_widget_out: Option<crate::widgets::RenderOutput> =
2766            prompt.toolbar_widget.as_ref().map(|spec| {
2767                crate::widgets::render_spec_no_autofocus(
2768                    spec,
2769                    &std::collections::HashMap::new(),
2770                    toolbar_focus_key,
2771                    inner.width as u32,
2772                )
2773            });
2774
2775        // Layout: a full-width HEADER band (input + toolbar + separator)
2776        // spans the whole inner width at the top; the BODY below it splits
2777        // into results | preview; a full-width FOOTER (when the plugin set
2778        // one) sits at the very bottom. This gives the toolbar the entire
2779        // pane width — the scope checkboxes don't fit when squeezed into the
2780        // left half beside the preview — and places the preview *under* the
2781        // toolbar, side-by-side with the result list. See
2782        // docs/internal/global-search-ux.md §12.
2783        let toolbar_h: u16 = match &toolbar_widget_out {
2784            Some(out) => out.entries.len() as u16,
2785            None if !prompt.title.is_empty() => 1,
2786            None => 0,
2787        };
2788        let footer_h: u16 = if prompt.footer.is_empty() { 0 } else { 1 };
2789        // Header rows = input(1) + toolbar(toolbar_h) + separator(1).
2790        let header_h: u16 = 2 + toolbar_h;
2791        let body = Rect {
2792            x: inner.x,
2793            y: inner.y.saturating_add(header_h),
2794            width: inner.width,
2795            height: inner.height.saturating_sub(header_h + footer_h),
2796        };
2797
2798        // Split the body into results | preview. Below ~120 cols, stack
2799        // results-only (preview hidden — see design doc §5 "preview pane size
2800        // when terminal is narrow").
2801        let preview_min_cols: u16 = 120;
2802        let show_preview = overlay_rect.width >= preview_min_cols && body.height > 0;
2803        let (results_area, preview_area) = if show_preview {
2804            let results_w = body.width / 2;
2805            (
2806                Rect {
2807                    x: body.x,
2808                    y: body.y,
2809                    width: results_w,
2810                    height: body.height,
2811                },
2812                Some(Rect {
2813                    x: body.x + results_w,
2814                    y: body.y,
2815                    width: body.width - results_w,
2816                    height: body.height,
2817                }),
2818            )
2819        } else {
2820            (body, None)
2821        };
2822
2823        // Cache the result/preview rects so the mouse-wheel handler can route
2824        // the wheel to the pane under the pointer (issue #2119).
2825        self.active_chrome_mut().prompt_results_area = Some(results_area);
2826        self.active_chrome_mut().prompt_preview_area = preview_area;
2827
2828        // The prompt input is the full-width top row of the header band.
2829        let input_row = Rect {
2830            x: inner.x,
2831            y: inner.y,
2832            width: inner.width,
2833            height: 1,
2834        };
2835        // Two distinct styles on this row so the user can tell
2836        // the static title (`prompt.message`) apart from the
2837        // editable input field. Title gets the popup-chrome bg
2838        // (matching the toolbar/footer); input + right-side
2839        // padding + count get the editor bg so they read as one
2840        // contiguous text field. All colours from theme keys.
2841        let title_style = Style::default()
2842            .fg(theme.suggestion_fg)
2843            .bg(theme.suggestion_bg);
2844        let input_style = Style::default().fg(theme.editor_fg).bg(theme.editor_bg);
2845        let count_str = if prompt.suggestions.is_empty() {
2846            String::new()
2847        } else {
2848            format!(
2849                "{} / {}",
2850                prompt.selected_suggestion.map(|i| i + 1).unwrap_or(0),
2851                prompt.suggestions.len()
2852            )
2853        };
2854        use crate::primitives::display_width::str_width;
2855        let count_w = str_width(&count_str);
2856        // Reserve one trailing column so the count doesn't sit
2857        // flush against the right border.
2858        let right_gap: usize = if count_w > 0 { 1 } else { 0 };
2859        // Right cluster: "<status>  <count>" — the plugin's search status
2860        // (e.g. "Searching…", "No matches") sits just left of the count, so
2861        // it's on the same row the user is typing on rather than a wasted
2862        // chrome row. Two-space gap between status and count when both show.
2863        let status_str = prompt.status.clone();
2864        let status_w = str_width(&status_str);
2865        let status_gap: usize = if status_w > 0 && count_w > 0 { 2 } else { 0 };
2866        let right_cluster_w = status_w + status_gap + count_w + right_gap;
2867        let visible_input_width = (input_row.width as usize).saturating_sub(right_cluster_w);
2868        let truncated_input: String = prompt
2869            .input
2870            .chars()
2871            .take(visible_input_width.saturating_sub(str_width(&prompt.message)))
2872            .collect();
2873        // Pad between the typed input and the right cluster so the count is
2874        // right-aligned (with `right_gap` empty cols at the very edge),
2875        // independent of how much the user has typed.
2876        let used = str_width(&prompt.message) + str_width(&truncated_input) + right_cluster_w;
2877        let pad = (input_row.width as usize).saturating_sub(used);
2878        let dim = Style::default()
2879            .fg(theme.popup_border_fg)
2880            .bg(theme.editor_bg);
2881        let line = Line::from(vec![
2882            Span::styled(prompt.message.clone(), title_style),
2883            Span::styled(truncated_input, input_style),
2884            Span::styled(" ".repeat(pad), input_style),
2885            Span::styled(status_str, dim),
2886            Span::styled(" ".repeat(status_gap), input_style),
2887            Span::styled(count_str, dim),
2888        ]);
2889        frame.render_widget(Paragraph::new(line).style(input_style), input_row);
2890
2891        // Cursor position on the input row — only when the input is focused.
2892        // When a toolbar control owns focus, the highlighted toggle is the
2893        // focus indicator and the input caret would be misleading.
2894        let input_focused = prompt.toolbar_focus.is_none();
2895        let cursor_x = (str_width(&prompt.message)
2896            + str_width(&prompt.input[..prompt.cursor_pos.min(prompt.input.len())]))
2897            as u16;
2898        if input_focused && cursor_x < input_row.width {
2899            frame.set_cursor_position((input_row.x + cursor_x, input_row.y));
2900        }
2901
2902        // Optional toolbar row (the styled segments the plugin set
2903        // via setPromptTitle, e.g. "Provider: rg · Alt+P switch
2904        // grep provider · …"). Sits between the input row and the
2905        // separator so the user sees feature-scoped controls right
2906        // under what they're typing — not on the frame border
2907        // where shortcut hints get visually lost.
2908        self.active_chrome_mut().prompt_toolbar_hits.clear();
2909        if let Some(out) = &toolbar_widget_out {
2910            // Widget toolbar: paint each rendered row across the full width
2911            // and record screen-space hit rects (key → rect) for click
2912            // routing. `HitArea` carries byte offsets within the row's text;
2913            // convert to display columns so the rect lines up with the glyphs.
2914            use crate::primitives::display_width::str_width;
2915            let band_y = inner.y + 1;
2916            for (i, entry) in out.entries.iter().enumerate() {
2917                let y = band_y + i as u16;
2918                if y >= inner.y + inner.height {
2919                    break;
2920                }
2921                paint_text_property_entry(frame, entry, inner.x, y, inner.width, &theme);
2922            }
2923            for hit in &out.hits {
2924                if hit.widget_key.is_empty() {
2925                    continue;
2926                }
2927                let Some(entry) = out.entries.get(hit.buffer_row as usize) else {
2928                    continue;
2929                };
2930                let text = &entry.text;
2931                let start_col = str_width(text.get(..hit.byte_start).unwrap_or(""));
2932                let end_col = str_width(text.get(..hit.byte_end).unwrap_or(text));
2933                let y = band_y + hit.buffer_row as u16;
2934                let rect = Rect {
2935                    x: inner.x + start_col as u16,
2936                    y,
2937                    width: (end_col.saturating_sub(start_col)) as u16,
2938                    height: 1,
2939                };
2940                self.active_chrome_mut()
2941                    .prompt_toolbar_hits
2942                    .push((hit.widget_key.clone(), rect));
2943            }
2944        } else if !prompt.title.is_empty() && inner.height >= 2 {
2945            let toolbar = Rect {
2946                x: inner.x,
2947                y: inner.y + 1,
2948                width: inner.width,
2949                height: 1,
2950            };
2951            frame.render_widget(
2952                Paragraph::new(Line::from(title_spans))
2953                    .style(Style::default().bg(theme.suggestion_bg)),
2954                toolbar,
2955            );
2956        }
2957
2958        // Separator row (full width), closing the header band.
2959        if inner.height >= 2 + toolbar_h {
2960            let sep = Rect {
2961                x: inner.x,
2962                y: inner.y + 1 + toolbar_h,
2963                width: inner.width,
2964                height: 1,
2965            };
2966            let sep_style = Style::default()
2967                .fg(theme.popup_border_fg)
2968                .bg(theme.suggestion_bg);
2969            let sep_text = "─".repeat(inner.width as usize);
2970            frame.render_widget(Paragraph::new(sep_text).style(sep_style), sep);
2971        }
2972
2973        // Suggestions list fills `results_area` (the left half of the body)
2974        // entirely — the input, toolbar and separator now live in the header
2975        // band above, and the footer is a separate full-width row below, so
2976        // there's no in-column chrome to subtract here. Carve off the
2977        // rightmost 1-column lane for a scrollbar so the user can see how far
2978        // through the result set the selection is — only when the result set
2979        // actually exceeds the visible rows; otherwise the scrollbar is
2980        // visual noise.
2981        if results_area.height >= 1 {
2982            // No `-2` for popup-own-border — we render the
2983            // borderless variant below since the overlay frame is
2984            // already a border.
2985            let inner_rows = results_area.height as usize;
2986            let needs_scrollbar = prompt.suggestions.len() > inner_rows.max(1);
2987            let scrollbar_w: u16 = if needs_scrollbar { 1 } else { 0 };
2988            let list_area = Rect {
2989                x: results_area.x,
2990                y: results_area.y,
2991                width: results_area.width.saturating_sub(scrollbar_w),
2992                height: results_area.height,
2993            };
2994            self.active_chrome_mut().suggestions_area = SuggestionsRenderer::render_with_hover(
2995                frame,
2996                list_area,
2997                &prompt,
2998                &theme,
2999                self.active_window_mut().mouse_state.hover_target.as_ref(),
3000                false,
3001            );
3002            if self.active_chrome_mut().suggestions_area.is_some() {
3003                self.active_chrome_mut().suggestions_outer_area = Some(list_area);
3004            }
3005            // Render the scrollbar in the carved lane. Reuses the
3006            // shared `view::ui::scrollbar` widget so thumb sizing
3007            // and theme colours match scrollbars elsewhere in the
3008            // editor (split rendering, file explorer, …).
3009            if needs_scrollbar {
3010                use crate::view::ui::scrollbar::{
3011                    render_scrollbar, ScrollbarColors, ScrollbarState,
3012                };
3013                // Scrollbar rect aligns with the borderless
3014                // suggestions list — same y/height as the list itself
3015                // since there's no popup-own border to skip.
3016                let scrollbar_rect = Rect {
3017                    x: results_area.x + results_area.width - 1,
3018                    y: list_area.y,
3019                    width: 1,
3020                    height: list_area.height,
3021                };
3022                let state = ScrollbarState::new(
3023                    prompt.suggestions.len(),
3024                    inner_rows.max(1),
3025                    prompt.scroll_offset,
3026                );
3027                render_scrollbar(
3028                    frame,
3029                    scrollbar_rect,
3030                    &state,
3031                    &ScrollbarColors::from_theme(&theme),
3032                );
3033                // Cache the rect for mouse hit testing in
3034                // `mouse_input.rs::handle_click_prompt_scrollbar`.
3035                self.active_chrome_mut().suggestions_scrollbar_rect = Some(scrollbar_rect);
3036            } else {
3037                self.active_chrome_mut().suggestions_scrollbar_rect = None;
3038            }
3039        } else {
3040            self.active_chrome_mut().suggestions_scrollbar_rect = None;
3041        }
3042
3043        // Plugin-supplied footer chrome row (Primitive #2 chrome
3044        // region). Each segment is a `StyledText` — same styling
3045        // primitive used by `setPromptTitle` and inline overlays,
3046        // so plugins can theme hotkey hints with `ui.help_key_fg`,
3047        // separators with `ui.popup_border_fg`, etc.
3048        if footer_h == 1 && inner.height >= 1 {
3049            let footer_row = Rect {
3050                x: inner.x,
3051                y: inner.y + inner.height - 1,
3052                width: inner.width,
3053                height: 1,
3054            };
3055            let footer_default_style = Style::default()
3056                .fg(theme.suggestion_fg)
3057                .bg(theme.suggestion_bg);
3058            let footer_spans: Vec<Span> = prompt
3059                .footer
3060                .iter()
3061                .map(|seg| {
3062                    let style = match &seg.style {
3063                        Some(opts) => Self::resolve_overlay_style(opts, &theme),
3064                        None => footer_default_style,
3065                    };
3066                    Span::styled(seg.text.clone(), style)
3067                })
3068                .collect();
3069            frame.render_widget(
3070                Paragraph::new(Line::from(footer_spans))
3071                    .style(Style::default().bg(theme.suggestion_bg)),
3072                footer_row,
3073            );
3074        }
3075
3076        // Right-half preview pane: a real Buffer rendered via the
3077        // same per-leaf pipeline regular splits use. Buffer + cursor
3078        // are already seeded by `prepare_overlay_preview` (called
3079        // earlier in the render flow). Borrows are split here so we
3080        // can hand out independent `&mut` references to the
3081        // renderer's internals without going back through `&mut self`.
3082        if let Some(preview_rect) = preview_area {
3083            // Frame the preview area first (vertical separator) so
3084            // the renderer fills the inner rect.
3085            use ratatui::widgets::{Block, Borders, Clear};
3086            frame.render_widget(Clear, preview_rect);
3087            let block = Block::default()
3088                .borders(Borders::LEFT)
3089                .border_style(Style::default().fg(theme.popup_border_fg))
3090                .style(Style::default().bg(theme.suggestion_bg));
3091            let inner = block.inner(preview_rect);
3092            frame.render_widget(block, preview_rect);
3093
3094            // Primitive #1: if the active plugin asked us to
3095            // preview a specific (inactive) session in this
3096            // rect, render that session's entire stashed split
3097            // tree natively into `inner`. Falls back to the
3098            // existing path-based phantom-leaf preview when no
3099            // session override is set.
3100            if inner.height > 0
3101                && inner.width > 0
3102                && self
3103                    .preview_window_id
3104                    .is_some_and(|sid| sid != self.active_window && self.windows.contains_key(&sid))
3105            {
3106                self.render_session_preview_into_rect(frame, inner, &theme);
3107            } else if inner.height > 0 && inner.width > 0 {
3108                // Snapshot scalar config values up front so the
3109                // mutable-borrow split below has minimal scope.
3110                // AnsiBackground isn't Clone, so it's taken as a
3111                // borrow; Rust permits disjoint-field splitting
3112                // between `&self.ansi_background` and the `&mut`
3113                // accesses below because they touch distinct fields.
3114                let bg_fade = self.background_fade;
3115                let estimated_line_length = self.config.editor.estimated_line_length;
3116                let highlight_context_bytes = self.config.editor.highlight_context_bytes;
3117                let relative_line_numbers = self.config.editor.relative_line_numbers;
3118                let use_terminal_bg = self.config.editor.use_terminal_bg;
3119                let session_mode = self.session_mode || !self.software_cursor_only;
3120                let software_cursor_only = self.software_cursor_only;
3121                let diagnostics_inline_text = self.config.editor.diagnostics_inline_text;
3122                let show_tilde = false; // preview hides tilde markers
3123                let highlight_current_column = self.config.editor.highlight_current_column;
3124                let screen_width = frame.area().width;
3125
3126                let ansi_ref = self.ansi_background.as_ref();
3127                let __win = self
3128                    .windows
3129                    .get_mut(&self.active_window)
3130                    .expect("active window present");
3131                let buffers = &mut __win.buffers;
3132                let event_logs = &mut __win.event_logs;
3133                let cell_theme_map = &mut __win.chrome_layout.cell_theme_map;
3134                let Some(preview_state) = __win.overlay_preview_state.as_mut() else {
3135                    return;
3136                };
3137                // Blanked: the current query has no selectable result, so
3138                // leave the framed pane empty rather than rendering a stale
3139                // match.
3140                if preview_state.blanked {
3141                    return;
3142                }
3143                preview_state
3144                    .view_state
3145                    .viewport
3146                    .resize(inner.width, inner.height);
3147                let buffer_id = preview_state.buffer_id;
3148
3149                if let Some(state) = buffers.get_mut(&buffer_id) {
3150                    // Deref the SplitViewState once to a concrete
3151                    // `&mut BufferViewState` so disjoint field
3152                    // splits (`viewport` + `folds`) are visible
3153                    // to the borrow checker.
3154                    let buf_state = preview_state.view_state.active_state_mut();
3155                    let cursors = buf_state.cursors.clone();
3156                    let view_mode = buf_state.view_mode.clone();
3157                    let compose_width = buf_state.compose_width;
3158                    let compose_column_guides = buf_state.compose_column_guides.clone();
3159                    let view_transform = buf_state.view_transform.clone();
3160                    let rulers = buf_state.rulers.clone();
3161                    let show_line_numbers = buf_state.show_line_numbers;
3162                    let highlight_current_line = buf_state.highlight_current_line;
3163                    let viewport_ref = &mut buf_state.viewport;
3164                    let folds_ref = &mut buf_state.folds;
3165                    let event_log = event_logs.get_mut(&buffer_id);
3166                    let _ = crate::view::ui::SplitRenderer::render_phantom_leaf(
3167                        frame,
3168                        state,
3169                        &cursors,
3170                        viewport_ref,
3171                        folds_ref,
3172                        event_log,
3173                        inner,
3174                        &theme,
3175                        ansi_ref,
3176                        bg_fade,
3177                        view_mode,
3178                        compose_width,
3179                        compose_column_guides,
3180                        view_transform,
3181                        estimated_line_length,
3182                        highlight_context_bytes,
3183                        buffer_id,
3184                        relative_line_numbers,
3185                        use_terminal_bg,
3186                        session_mode,
3187                        software_cursor_only,
3188                        &rulers,
3189                        show_line_numbers,
3190                        highlight_current_line,
3191                        diagnostics_inline_text,
3192                        show_tilde,
3193                        highlight_current_column,
3194                        cell_theme_map,
3195                        screen_width,
3196                    );
3197                }
3198            }
3199        }
3200    }
3201
3202    /// Render hover highlights for interactive elements (separators, scrollbars)
3203    pub(super) fn render_hover_highlights(&self, frame: &mut Frame) {
3204        use ratatui::style::Style;
3205        use ratatui::text::Span;
3206        use ratatui::widgets::Paragraph;
3207
3208        match &self.active_window().mouse_state.hover_target {
3209            Some(HoverTarget::SplitSeparator(split_id, direction)) => {
3210                // Highlight the separator with hover color
3211                for (sid, dir, x, y, length) in &self.active_layout().separator_areas {
3212                    if sid == split_id && dir == direction {
3213                        let (hover_fg, editor_bg) = {
3214                            let theme = self.theme.read().unwrap();
3215                            (theme.split_separator_hover_fg, theme.editor_bg)
3216                        };
3217                        let hover_style = Style::default().fg(hover_fg).bg(editor_bg);
3218                        match dir {
3219                            SplitDirection::Horizontal => {
3220                                let line_text = "─".repeat(*length as usize);
3221                                let paragraph =
3222                                    Paragraph::new(Span::styled(line_text, hover_style));
3223                                frame.render_widget(
3224                                    paragraph,
3225                                    ratatui::layout::Rect::new(*x, *y, *length, 1),
3226                                );
3227                            }
3228                            SplitDirection::Vertical => {
3229                                for offset in 0..*length {
3230                                    let paragraph = Paragraph::new(Span::styled("│", hover_style));
3231                                    frame.render_widget(
3232                                        paragraph,
3233                                        ratatui::layout::Rect::new(*x, y + offset, 1, 1),
3234                                    );
3235                                }
3236                            }
3237                        }
3238                    }
3239                }
3240            }
3241            Some(HoverTarget::ScrollbarThumb(split_id)) => {
3242                // Highlight scrollbar thumb
3243                for (sid, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
3244                    &self.active_layout().split_areas
3245                {
3246                    if sid == split_id {
3247                        let hover_style = Style::default().bg(self
3248                            .theme
3249                            .read()
3250                            .unwrap()
3251                            .scrollbar_thumb_hover_fg);
3252                        for row_offset in *thumb_start..*thumb_end {
3253                            let paragraph = Paragraph::new(Span::styled(" ", hover_style));
3254                            frame.render_widget(
3255                                paragraph,
3256                                ratatui::layout::Rect::new(
3257                                    scrollbar_rect.x,
3258                                    scrollbar_rect.y + row_offset as u16,
3259                                    1,
3260                                    1,
3261                                ),
3262                            );
3263                        }
3264                    }
3265                }
3266            }
3267            Some(HoverTarget::ScrollbarTrack(split_id, hovered_row)) => {
3268                // Highlight only the hovered cell on the scrollbar track
3269                for (sid, _buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
3270                    &self.active_layout().split_areas
3271                {
3272                    if sid == split_id {
3273                        let track_hover_style = Style::default().bg(self
3274                            .theme
3275                            .read()
3276                            .unwrap()
3277                            .scrollbar_track_hover_fg);
3278                        let paragraph = Paragraph::new(Span::styled(" ", track_hover_style));
3279                        frame.render_widget(
3280                            paragraph,
3281                            ratatui::layout::Rect::new(
3282                                scrollbar_rect.x,
3283                                scrollbar_rect.y + hovered_row,
3284                                1,
3285                                1,
3286                            ),
3287                        );
3288                    }
3289                }
3290            }
3291            Some(HoverTarget::FileExplorerBorder) => {
3292                // Highlight the file explorer border for resize
3293                if let Some(explorer_area) = self.active_layout().file_explorer_area {
3294                    let (hover_fg, editor_bg) = {
3295                        let theme = self.theme.read().unwrap();
3296                        (theme.split_separator_hover_fg, theme.editor_bg)
3297                    };
3298                    let hover_style = Style::default().fg(hover_fg).bg(editor_bg);
3299                    let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
3300                    for row_offset in 0..explorer_area.height {
3301                        let paragraph = Paragraph::new(Span::styled("│", hover_style));
3302                        frame.render_widget(
3303                            paragraph,
3304                            ratatui::layout::Rect::new(
3305                                border_x,
3306                                explorer_area.y + row_offset,
3307                                1,
3308                                1,
3309                            ),
3310                        );
3311                    }
3312                }
3313            }
3314            // Menu hover is handled by MenuRenderer
3315            _ => {}
3316        }
3317    }
3318
3319    /// Render the tab context menu
3320    fn render_tab_context_menu(&self, frame: &mut Frame, menu: &TabContextMenu) {
3321        use ratatui::style::Style;
3322        use ratatui::text::{Line, Span};
3323        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
3324
3325        let items = super::types::TabContextMenuItem::all();
3326        let menu_width = 22u16; // "Close to the Right" + padding
3327        let menu_height = items.len() as u16 + 2; // items + borders
3328
3329        // Adjust position to stay within screen bounds
3330        let screen_width = frame.area().width;
3331        let screen_height = frame.area().height;
3332
3333        let menu_x = if menu.position.0 + menu_width > screen_width {
3334            screen_width.saturating_sub(menu_width)
3335        } else {
3336            menu.position.0
3337        };
3338
3339        let menu_y = if menu.position.1 + menu_height > screen_height {
3340            screen_height.saturating_sub(menu_height)
3341        } else {
3342            menu.position.1
3343        };
3344
3345        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
3346
3347        // Clear the area first
3348        frame.render_widget(Clear, area);
3349
3350        // Build the menu lines
3351        let mut lines = Vec::new();
3352        for (idx, item) in items.iter().enumerate() {
3353            let is_highlighted = idx == menu.highlighted;
3354
3355            let style = if is_highlighted {
3356                Style::default()
3357                    .fg(self.theme.read().unwrap().menu_highlight_fg)
3358                    .bg(self.theme.read().unwrap().menu_highlight_bg)
3359            } else {
3360                Style::default()
3361                    .fg(self.theme.read().unwrap().menu_dropdown_fg)
3362                    .bg(self.theme.read().unwrap().menu_dropdown_bg)
3363            };
3364
3365            // Pad the label to fill the menu width
3366            let label = item.label();
3367            let content_width = (menu_width as usize).saturating_sub(2); // -2 for borders
3368            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
3369
3370            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
3371        }
3372
3373        let block = Block::default()
3374            .borders(Borders::ALL)
3375            .border_style(Style::default().fg(self.theme.read().unwrap().menu_border_fg))
3376            .style(Style::default().bg(self.theme.read().unwrap().menu_dropdown_bg));
3377
3378        let paragraph = Paragraph::new(lines).block(block);
3379        frame.render_widget(paragraph, area);
3380    }
3381
3382    /// Render the file explorer context menu
3383    fn render_file_explorer_context_menu(
3384        &self,
3385        frame: &mut Frame,
3386        menu: &super::types::FileExplorerContextMenu,
3387    ) {
3388        use ratatui::style::Style;
3389        use ratatui::text::{Line, Span};
3390        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
3391
3392        let items = menu.items();
3393        let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
3394        let menu_height = menu.height();
3395        let (menu_x, menu_y) = menu.clamped_position(frame.area().width, frame.area().height);
3396
3397        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
3398
3399        frame.render_widget(Clear, area);
3400
3401        let mut lines = Vec::new();
3402        for (idx, item) in items.iter().enumerate() {
3403            let is_highlighted = idx == menu.highlighted;
3404
3405            let style = if is_highlighted {
3406                Style::default()
3407                    .fg(self.theme.read().unwrap().menu_highlight_fg)
3408                    .bg(self.theme.read().unwrap().menu_highlight_bg)
3409            } else {
3410                Style::default()
3411                    .fg(self.theme.read().unwrap().menu_dropdown_fg)
3412                    .bg(self.theme.read().unwrap().menu_dropdown_bg)
3413            };
3414
3415            let label = item.label();
3416            let content_width = (menu_width as usize).saturating_sub(2);
3417            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
3418
3419            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
3420        }
3421
3422        let block = Block::default()
3423            .borders(Borders::ALL)
3424            .border_style(Style::default().fg(self.theme.read().unwrap().menu_border_fg))
3425            .style(Style::default().bg(self.theme.read().unwrap().menu_dropdown_bg));
3426
3427        let paragraph = Paragraph::new(lines).block(block);
3428        frame.render_widget(paragraph, area);
3429    }
3430
3431    /// Render the tab drag drop zone overlay
3432    fn render_tab_drop_zone(&self, frame: &mut Frame, drag_state: &super::types::TabDragState) {
3433        use ratatui::style::Modifier;
3434
3435        let Some(ref drop_zone) = drag_state.drop_zone else {
3436            return;
3437        };
3438
3439        let split_id = drop_zone.split_id();
3440
3441        // Find the content area for the target split
3442        let split_area = self
3443            .active_layout()
3444            .split_areas
3445            .iter()
3446            .find(|(sid, _, _, _, _, _)| *sid == split_id)
3447            .map(|(_, _, content_rect, _, _, _)| *content_rect);
3448
3449        let Some(content_rect) = split_area else {
3450            return;
3451        };
3452
3453        // Determine the highlight area based on drop zone type
3454        use super::types::TabDropZone;
3455
3456        let highlight_area = match drop_zone {
3457            TabDropZone::TabBar(_, _) | TabDropZone::SplitCenter(_) => {
3458                // For tab bar and center drops, highlight the entire split area
3459                // This indicates the tab will be added to this split's tab bar
3460                content_rect
3461            }
3462            TabDropZone::SplitLeft(_) => {
3463                // Left 50% of the split (matches the actual split size created)
3464                let width = (content_rect.width / 2).max(3);
3465                ratatui::layout::Rect::new(
3466                    content_rect.x,
3467                    content_rect.y,
3468                    width,
3469                    content_rect.height,
3470                )
3471            }
3472            TabDropZone::SplitRight(_) => {
3473                // Right 50% of the split (matches the actual split size created)
3474                let width = (content_rect.width / 2).max(3);
3475                let x = content_rect.x + content_rect.width - width;
3476                ratatui::layout::Rect::new(x, content_rect.y, width, content_rect.height)
3477            }
3478            TabDropZone::SplitTop(_) => {
3479                // Top 50% of the split (matches the actual split size created)
3480                let height = (content_rect.height / 2).max(2);
3481                ratatui::layout::Rect::new(
3482                    content_rect.x,
3483                    content_rect.y,
3484                    content_rect.width,
3485                    height,
3486                )
3487            }
3488            TabDropZone::SplitBottom(_) => {
3489                // Bottom 50% of the split (matches the actual split size created)
3490                let height = (content_rect.height / 2).max(2);
3491                let y = content_rect.y + content_rect.height - height;
3492                ratatui::layout::Rect::new(content_rect.x, y, content_rect.width, height)
3493            }
3494        };
3495
3496        // Draw the overlay with the drop zone color
3497        // We apply a semi-transparent effect by modifying existing cells
3498        let buf = frame.buffer_mut();
3499        let drop_zone_bg = self.theme.read().unwrap().tab_drop_zone_bg;
3500        let drop_zone_border = self.theme.read().unwrap().tab_drop_zone_border;
3501
3502        // Fill the highlight area with a semi-transparent overlay
3503        for y in highlight_area.y..highlight_area.y + highlight_area.height {
3504            for x in highlight_area.x..highlight_area.x + highlight_area.width {
3505                if let Some(cell) = buf.cell_mut((x, y)) {
3506                    // Blend the drop zone color with the existing background
3507                    // For a simple effect, we just set the background
3508                    cell.set_bg(drop_zone_bg);
3509
3510                    // Draw border on edges
3511                    let is_border = x == highlight_area.x
3512                        || x == highlight_area.x + highlight_area.width - 1
3513                        || y == highlight_area.y
3514                        || y == highlight_area.y + highlight_area.height - 1;
3515
3516                    if is_border {
3517                        cell.set_fg(drop_zone_border);
3518                        cell.set_style(cell.style().add_modifier(Modifier::BOLD));
3519                    }
3520                }
3521            }
3522        }
3523
3524        // Draw a border indicator based on the zone type
3525        match drop_zone {
3526            TabDropZone::SplitLeft(_) => {
3527                // Draw vertical indicator on left edge
3528                for y in highlight_area.y..highlight_area.y + highlight_area.height {
3529                    if let Some(cell) = buf.cell_mut((highlight_area.x, y)) {
3530                        cell.set_symbol("▌");
3531                        cell.set_fg(drop_zone_border);
3532                    }
3533                }
3534            }
3535            TabDropZone::SplitRight(_) => {
3536                // Draw vertical indicator on right edge
3537                let x = highlight_area.x + highlight_area.width - 1;
3538                for y in highlight_area.y..highlight_area.y + highlight_area.height {
3539                    if let Some(cell) = buf.cell_mut((x, y)) {
3540                        cell.set_symbol("▐");
3541                        cell.set_fg(drop_zone_border);
3542                    }
3543                }
3544            }
3545            TabDropZone::SplitTop(_) => {
3546                // Draw horizontal indicator on top edge
3547                for x in highlight_area.x..highlight_area.x + highlight_area.width {
3548                    if let Some(cell) = buf.cell_mut((x, highlight_area.y)) {
3549                        cell.set_symbol("▀");
3550                        cell.set_fg(drop_zone_border);
3551                    }
3552                }
3553            }
3554            TabDropZone::SplitBottom(_) => {
3555                // Draw horizontal indicator on bottom edge
3556                let y = highlight_area.y + highlight_area.height - 1;
3557                for x in highlight_area.x..highlight_area.x + highlight_area.width {
3558                    if let Some(cell) = buf.cell_mut((x, y)) {
3559                        cell.set_symbol("▄");
3560                        cell.set_fg(drop_zone_border);
3561                    }
3562                }
3563            }
3564            TabDropZone::SplitCenter(_) | TabDropZone::TabBar(_, _) => {
3565                // For center and tab bar, the filled background is sufficient
3566            }
3567        }
3568    }
3569
3570    /// Recompute the view_line_mappings layout without drawing.
3571    /// Used during macro replay so that visual-line movements (MoveLineEnd,
3572    /// MoveUp, MoveDown on wrapped lines) see correct, up-to-date layout
3573    /// information between each replayed action.
3574    pub fn recompute_layout(&mut self, width: u16, height: u16) {
3575        let size = ratatui::layout::Rect::new(0, 0, width, height);
3576
3577        // Replicate the pre-render sync steps from render()
3578        let active_split = self
3579            .windows
3580            .get(&self.active_window)
3581            .and_then(|w| w.buffers.splits())
3582            .map(|(mgr, _)| mgr)
3583            .expect("active window must have a populated split layout")
3584            .active_split();
3585        self.active_window_mut()
3586            .pre_sync_ensure_visible(active_split);
3587        self.active_window_mut().sync_scroll_groups();
3588
3589        // Replicate the layout computation that produces editor_content_area.
3590        // Same constraints as render(): [menu_bar, main_content, status_bar, search_options, prompt_line]
3591        let constraints = vec![
3592            Constraint::Length(if self.active_window_mut().menu_bar_visible {
3593                1
3594            } else {
3595                0
3596            }),
3597            Constraint::Min(0),
3598            Constraint::Length(if self.active_window_mut().status_bar_visible {
3599                1
3600            } else {
3601                0
3602            }), // status bar
3603            Constraint::Length(0), // search options (doesn't matter for layout)
3604            Constraint::Length(if self.active_window_mut().prompt_line_visible {
3605                1
3606            } else {
3607                0
3608            }), // prompt line
3609        ];
3610        let main_chunks = Layout::default()
3611            .direction(Direction::Vertical)
3612            .constraints(constraints)
3613            .split(size);
3614        let main_content_area = main_chunks[1];
3615
3616        // Compute editor_content_area (with file explorer split if visible)
3617        let file_explorer_should_show = self.file_explorer_visible()
3618            && (self.file_explorer().is_some()
3619                || self.active_window().file_explorer_sync_in_progress);
3620        let editor_content_area = if file_explorer_should_show {
3621            let explorer_cols = self
3622                .active_window()
3623                .file_explorer_width
3624                .to_cols(main_content_area.width);
3625            let horizontal_chunks = Layout::default()
3626                .direction(Direction::Horizontal)
3627                .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
3628                .split(main_content_area);
3629            horizontal_chunks[1]
3630        } else {
3631            main_content_area
3632        };
3633
3634        // Compute layout for all visible splits and update cached view_line_mappings.
3635        // Take one &mut borrow on the active window's splits; destructure into
3636        // (&SplitManager, &mut HashMap<...>) so both arguments come from the
3637        // same `&mut self.windows` borrow.
3638        let active_window_id = self.active_window;
3639        let __win_l = self
3640            .windows
3641            .get_mut(&active_window_id)
3642            .expect("active window must exist");
3643        let tab_bar_visible = __win_l.tab_bar_visible;
3644        let theme = self.theme.read().unwrap().clone();
3645        let view_line_mappings = __win_l
3646            .buffers
3647            .with_all_mut(|buffers, mgr, vs_map| {
3648                SplitRenderer::compute_content_layout(
3649                    editor_content_area,
3650                    &*mgr,
3651                    buffers,
3652                    vs_map,
3653                    &theme,
3654                    false, // lsp_waiting — not relevant for layout
3655                    self.config.editor.estimated_line_length,
3656                    self.config.editor.highlight_context_bytes,
3657                    self.config.editor.relative_line_numbers,
3658                    self.config.editor.use_terminal_bg,
3659                    self.session_mode || !self.software_cursor_only,
3660                    self.software_cursor_only,
3661                    tab_bar_visible,
3662                    self.config.editor.show_vertical_scrollbar,
3663                    self.config.editor.show_horizontal_scrollbar,
3664                    self.config.editor.diagnostics_inline_text,
3665                    self.config.editor.show_tilde,
3666                )
3667            })
3668            .expect("active window must have a populated split layout");
3669
3670        self.active_layout_mut().view_line_mappings = view_line_mappings;
3671    }
3672
3673    /// Clear the search history
3674    /// Used primarily for testing to ensure test isolation
3675    pub fn clear_search_history(&mut self) {
3676        if let Some(history) = self.active_window_mut().prompt_histories.get_mut("search") {
3677            history.clear();
3678        }
3679    }
3680
3681    /// Emit an OSC 2 escape sequence to set the host terminal's window/tab
3682    /// title based on the active buffer's display name and the project name
3683    /// (the working directory's last path component). Deduplicated against
3684    /// the last title we wrote so we don't spam stdout every frame.
3685    ///
3686    /// Gated by `editor.set_window_title` (default on). Terminals that
3687    /// don't implement OSC 2 silently drop the sequence.
3688    fn update_terminal_title(&mut self, display_name: &str) {
3689        if !self.config.editor.set_window_title {
3690            return;
3691        }
3692        let project_name = self.working_dir().file_name().and_then(|s| s.to_str());
3693        let new_title =
3694            crate::services::terminal_title::build_window_title(display_name, project_name);
3695        if self.last_window_title.as_deref() == Some(new_title.as_str()) {
3696            return;
3697        }
3698        crate::services::terminal_title::write_terminal_title(&new_title);
3699        self.last_window_title = Some(new_title);
3700    }
3701
3702    /// Save all prompt histories to disk
3703    /// Called on shutdown to persist history across sessions
3704    pub fn save_histories(&self) {
3705        // Ensure data directory exists
3706        if let Err(e) = self
3707            .authority
3708            .filesystem
3709            .create_dir_all(&self.dir_context.data_dir)
3710        {
3711            tracing::warn!("Failed to create data directory: {}", e);
3712            return;
3713        }
3714
3715        // Save all prompt histories
3716        for (key, history) in &self.active_window().prompt_histories {
3717            let path = self.dir_context.prompt_history_path(key);
3718            if let Err(e) = history.save_to_file(&path) {
3719                tracing::warn!("Failed to save {} history: {}", key, e);
3720            } else {
3721                tracing::debug!("Saved {} history to {:?}", key, path);
3722            }
3723        }
3724    }
3725
3726    /// Resolve a plugin-supplied [`OverlayOptions`] to a ratatui
3727    /// [`Style`] against the active theme. RGB colours pass through;
3728    /// theme keys (e.g. `"ui.help_key_fg"`) are looked up via
3729    /// `theme.resolve_theme_key`. Mirrors the resolution
3730    /// `OverlayFace::from_options` + char_style.rs do for buffer
3731    /// overlays — pulled here so the prompt-frame renderer can build
3732    /// styled spans inline.
3733    /// Compute a centered overlay rect of `width_pct` × `height_pct`
3734    /// of the given area. Mirrors `PopupPosition::CenteredOverlay`
3735    /// math used by `render_overlay_prompt`; minimum 20×8 cells so
3736    /// content stays legible on tiny terminals.
3737    pub(super) fn centered_overlay_rect(
3738        area: ratatui::layout::Rect,
3739        width_pct: u8,
3740        height_pct: u8,
3741    ) -> ratatui::layout::Rect {
3742        let w_pct = width_pct.clamp(1, 100) as u32;
3743        let h_pct = height_pct.clamp(1, 100) as u32;
3744        let w = ((area.width as u32 * w_pct) / 100) as u16;
3745        let h = ((area.height as u32 * h_pct) / 100) as u16;
3746        let w = w.max(20).min(area.width);
3747        let h = h.max(8).min(area.height);
3748        ratatui::layout::Rect {
3749            x: area.x + (area.width.saturating_sub(w)) / 2,
3750            y: area.y + (area.height.saturating_sub(h)) / 2,
3751            width: w,
3752            height: h,
3753        }
3754    }
3755
3756    /// Render the currently-mounted floating widget panel: dim the
3757    /// background outside the centered rect, draw the frame, paint
3758    /// the panel's rendered entries inside, and place the hardware
3759    /// caret at the focused TextInput. Stores the inner rect on the
3760    /// `FloatingWidgetState` so the click hit-test can recover the
3761    /// geometry on the next mouse event.
3762    pub(super) fn render_floating_widget_panel(
3763        &mut self,
3764        frame: &mut Frame,
3765        area: ratatui::layout::Rect,
3766    ) {
3767        use ratatui::widgets::{Block, Borders, Clear};
3768
3769        let (width_pct, height_pct, entries, focus_cursor, embeds, overlays, scroll_regions) =
3770            match self.floating_widget_panel.as_ref() {
3771                Some(fwp) => (
3772                    fwp.width_pct,
3773                    fwp.height_pct,
3774                    fwp.entries.clone(),
3775                    fwp.focus_cursor,
3776                    fwp.embeds.clone(),
3777                    fwp.overlays.clone(),
3778                    fwp.scroll_regions.clone(),
3779                ),
3780                None => return,
3781            };
3782        let theme = self.theme.read().unwrap().clone();
3783        // Compute the requested rect from width%/height%, then
3784        // shrink the height to fit the rendered content (Bug 7).
3785        // Plugins call `mount({widthPct, heightPct})` mostly because
3786        // they don't know how tall their content is up front; the
3787        // requested height should act as a *max*, not a fixed
3788        // canvas. Without this shrink, the new-session form's 10
3789        // content rows leave ~20 blank rows under "Tab next  S-Tab
3790        // prev  Enter submit  Esc cancel" inside a 90%-of-screen
3791        // panel.
3792        //
3793        // Entries include every row the spec produces — including
3794        // WindowEmbed reservations (each `windowEmbed({rows: N})`
3795        // contributes N blank entries plus an EmbedRect that paints
3796        // over them at draw time). So `entries.len() + 2` (top
3797        // border + content + bottom border) is the natural fit.
3798        let overlay_rect = {
3799            let requested = Self::centered_overlay_rect(area, width_pct, height_pct);
3800            let needed_h = (entries.len() as u16).saturating_add(2);
3801            let effective_h = needed_h.min(requested.height).max(3);
3802            ratatui::layout::Rect {
3803                x: requested.x,
3804                y: area.y + (area.height.saturating_sub(effective_h)) / 2,
3805                width: requested.width,
3806                height: effective_h,
3807            }
3808        };
3809
3810        crate::view::dimming::apply_dimming_excluding(frame, area, Some(overlay_rect));
3811        frame.render_widget(Clear, overlay_rect);
3812        let block = Block::default()
3813            .borders(Borders::ALL)
3814            .border_style(ratatui::style::Style::default().fg(theme.popup_border_fg))
3815            .style(ratatui::style::Style::default().bg(theme.suggestion_bg));
3816        let inner = block.inner(overlay_rect);
3817        frame.render_widget(block, overlay_rect);
3818
3819        if inner.width == 0 || inner.height == 0 {
3820            if let Some(fwp) = self.floating_widget_panel.as_mut() {
3821                fwp.last_inner_rect = Some(inner);
3822            }
3823            return;
3824        }
3825
3826        let max_rows = inner.height as usize;
3827        for (i, entry) in entries.iter().take(max_rows).enumerate() {
3828            paint_text_property_entry(
3829                frame,
3830                entry,
3831                inner.x,
3832                inner.y + i as u16,
3833                inner.width,
3834                &theme,
3835            );
3836        }
3837
3838        // Walk WindowEmbed widgets and paint their referenced
3839        // editor window into the cells they reserved. Each embed
3840        // rect is panel-relative; translate to screen cells via
3841        // `inner`. We temporarily borrow `preview_window_id` to
3842        // reuse the existing per-window paint path — it reads
3843        // that field to decide which session to draw.
3844        let saved_preview = self.preview_window_id;
3845        for emb in &embeds {
3846            if emb.window_id == 0 {
3847                continue;
3848            }
3849            let ex = inner.x.saturating_add(emb.col_in_row as u16);
3850            let ey = inner.y.saturating_add(emb.buffer_row as u16);
3851            // Clip the embed rect to the panel's inner area so a
3852            // partially-offscreen embed (tiny terminal) doesn't
3853            // paint into the frame border.
3854            let max_w = inner.x.saturating_add(inner.width).saturating_sub(ex);
3855            let max_h = inner.y.saturating_add(inner.height).saturating_sub(ey);
3856            let w = (emb.width_cols as u16).min(max_w);
3857            let h = (emb.height_rows as u16).min(max_h);
3858            if w == 0 || h == 0 {
3859                continue;
3860            }
3861            let rect = ratatui::layout::Rect {
3862                x: ex,
3863                y: ey,
3864                width: w,
3865                height: h,
3866            };
3867            self.preview_window_id = Some(fresh_core::WindowId(emb.window_id as u64));
3868            self.render_session_preview_into_rect(frame, rect, &theme);
3869        }
3870        self.preview_window_id = saved_preview;
3871
3872        // Paint a draggable scrollbar over the rightmost column of each
3873        // overflowing list, reusing the canonical `render_scrollbar` /
3874        // `ScrollbarState` (same path as the keybinding editor &
3875        // settings dialog). Record each track's screen rect + state so
3876        // the mouse handlers can hit-test press/drag against it.
3877        let mut scrollbar_tracks: Vec<super::WidgetScrollbarTrack> = Vec::new();
3878        {
3879            use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
3880            let colors = ScrollbarColors::from_theme(&theme);
3881            for region in &scroll_regions {
3882                // Scrollbar column = right edge of the list's column,
3883                // clamped inside the panel. Height = visible rows,
3884                // clamped to the panel bottom.
3885                let sb_x = inner
3886                    .x
3887                    .saturating_add(region.col_in_row as u16)
3888                    .saturating_add((region.width_cols.saturating_sub(1)) as u16)
3889                    .min(inner.x + inner.width.saturating_sub(1));
3890                let sb_y = inner.y.saturating_add(region.buffer_row as u16);
3891                if sb_y >= inner.y + inner.height {
3892                    continue;
3893                }
3894                let max_h = inner.y + inner.height - sb_y;
3895                let sb_h = (region.height_rows as u16).min(max_h);
3896                if sb_h == 0 {
3897                    continue;
3898                }
3899                let sb_rect = ratatui::layout::Rect {
3900                    x: sb_x,
3901                    y: sb_y,
3902                    width: 1,
3903                    height: sb_h,
3904                };
3905                let state = ScrollbarState::new(region.total, region.visible, region.scroll);
3906                render_scrollbar(frame, sb_rect, &state, &colors);
3907                scrollbar_tracks.push(super::WidgetScrollbarTrack {
3908                    list_key: region.list_key.clone(),
3909                    rect: sb_rect,
3910                    total: region.total,
3911                    visible: region.visible,
3912                    scroll: region.scroll,
3913                });
3914            }
3915        }
3916
3917        // Paint overlay rows AFTER the main entries + embeds. Each
3918        // overlay row sits on top of whatever's at its
3919        // `buffer_row` (the row it would have occupied if it
3920        // weren't floating). Used for dropdown completions
3921        // anchored to a text input — the completion list rows
3922        // overpaint the form's static rows beneath without
3923        // shifting them on every show / hide.
3924        //
3925        // Clear the row first so the underlying entry's text
3926        // doesn't bleed past the overlay's content width.
3927        // `Paragraph` only paints cells it has content for; a
3928        // bare `Clear` resets the row to the panel background
3929        // (the `Block` here just supplies the bg style — no
3930        // borders).
3931        let panel_bg = theme.popup_bg;
3932        let panel_bg_style = ratatui::style::Style::default().bg(panel_bg);
3933        for o in &overlays {
3934            let row_y = inner.y.saturating_add(o.buffer_row as u16);
3935            if row_y >= inner.y.saturating_add(inner.height) {
3936                continue;
3937            }
3938            let row_rect = ratatui::layout::Rect {
3939                x: inner.x,
3940                y: row_y,
3941                width: inner.width,
3942                height: 1,
3943            };
3944            frame.render_widget(Clear, row_rect);
3945            frame.render_widget(Block::default().style(panel_bg_style), row_rect);
3946            paint_text_property_entry(frame, &o.entry, inner.x, row_y, inner.width, &theme);
3947        }
3948
3949        if let Some(fc) = focus_cursor {
3950            let cx = inner.x.saturating_add(byte_to_screen_col(
3951                entries
3952                    .get(fc.buffer_row as usize)
3953                    .map(|e| e.text.as_str())
3954                    .unwrap_or(""),
3955                fc.byte_in_row as usize,
3956            ) as u16);
3957            let cy = inner.y.saturating_add(fc.buffer_row as u16);
3958            if cx < inner.x + inner.width && cy < inner.y + inner.height {
3959                frame.set_cursor_position((cx, cy));
3960            }
3961        } else {
3962            // No focused text input — the underlying editor's
3963            // `set_cursor_position` (called earlier in this frame)
3964            // would otherwise leave a hardware caret blinking
3965            // inside the dimmed buffer behind the panel. Park the
3966            // cursor on the floating panel's bottom-right corner
3967            // so it's hidden under the panel chrome instead of
3968            // bleeding through.
3969            let cx = inner.x + inner.width.saturating_sub(1);
3970            let cy = inner.y + inner.height.saturating_sub(1);
3971            frame.set_cursor_position((cx, cy));
3972        }
3973
3974        if let Some(fwp) = self.floating_widget_panel.as_mut() {
3975            fwp.last_inner_rect = Some(inner);
3976            fwp.scrollbar_tracks = scrollbar_tracks;
3977        }
3978    }
3979
3980    fn resolve_overlay_style(
3981        opts: &fresh_core::api::OverlayOptions,
3982        theme: &crate::view::theme::Theme,
3983    ) -> ratatui::style::Style {
3984        use crate::view::theme::named_color_from_str;
3985        use fresh_core::api::OverlayColorSpec;
3986        use ratatui::style::{Color, Modifier, Style};
3987
3988        let resolve = |spec: &OverlayColorSpec| -> Option<Color> {
3989            match spec {
3990                OverlayColorSpec::Rgb(r, g, b) => Some(Color::Rgb(*r, *g, *b)),
3991                OverlayColorSpec::ThemeKey(k) => {
3992                    named_color_from_str(k).or_else(|| theme.resolve_theme_key(k))
3993                }
3994            }
3995        };
3996
3997        let mut style = Style::default();
3998        if let Some(ref fg) = opts.fg {
3999            if let Some(c) = resolve(fg) {
4000                style = style.fg(c);
4001            }
4002        }
4003        if let Some(ref bg) = opts.bg {
4004            if let Some(c) = resolve(bg) {
4005                style = style.bg(c);
4006            }
4007        }
4008        let mut m = Modifier::empty();
4009        if opts.bold {
4010            m |= Modifier::BOLD;
4011        }
4012        if opts.italic {
4013            m |= Modifier::ITALIC;
4014        }
4015        if opts.underline {
4016            m |= Modifier::UNDERLINED;
4017        }
4018        if opts.strikethrough {
4019            m |= Modifier::CROSSED_OUT;
4020        }
4021        if !m.is_empty() {
4022            style = style.add_modifier(m);
4023        }
4024        style
4025    }
4026}
4027
4028/// Paint a single rendered widget entry into the frame buffer at
4029/// `(x, y)` over `width` cells. Resolves the entry's segments / inline
4030/// overlays to styled spans using the panel's theme; trailing columns
4031/// are filled with spaces in the panel's bg so the row reads as one
4032/// solid line.
4033fn paint_text_property_entry(
4034    frame: &mut ratatui::Frame,
4035    entry: &fresh_core::text_property::TextPropertyEntry,
4036    x: u16,
4037    y: u16,
4038    width: u16,
4039    theme: &crate::view::theme::Theme,
4040) {
4041    use ratatui::style::Style;
4042    use ratatui::text::{Line, Span};
4043    use ratatui::widgets::Paragraph;
4044
4045    let mut normalized = entry.clone();
4046    normalized.normalize_widths();
4047    let mut text = normalized.text.clone();
4048    while text.ends_with('\n') {
4049        text.pop();
4050    }
4051
4052    let base_bg = theme.suggestion_bg;
4053    let base_style = if let Some(opts) = normalized.style.as_ref() {
4054        // Resolve the entry's row-level style, then fill in the
4055        // suggestion_bg only when the style didn't supply one
4056        // of its own. Without this guard, calling `.bg(base_bg)`
4057        // unconditionally would wipe out a row-level
4058        // `popup_selection_bg` (the highlight on the completion
4059        // popup's selected candidate) — `Style::bg` is a
4060        // replacement, not a merge.
4061        let mut resolved = Editor::resolve_overlay_style(opts, theme);
4062        // Fill in the suggestion surface's fg/bg when the style didn't
4063        // supply its own — `suggestion_fg` is the foreground partner for
4064        // `suggestion_bg`. Without an fg default, unstyled toolbar text
4065        // (toggle labels, "save matches") fell through to the terminal's
4066        // default foreground, which is unreadable on light themes.
4067        if resolved.fg.is_none() {
4068            resolved = resolved.fg(theme.suggestion_fg);
4069        }
4070        if resolved.bg.is_none() {
4071            resolved.bg(base_bg)
4072        } else {
4073            resolved
4074        }
4075    } else {
4076        Style::default().fg(theme.suggestion_fg).bg(base_bg)
4077    };
4078
4079    // Split the line at inline-overlay byte boundaries so each
4080    // resulting span carries one consistent style. The overlays are
4081    // produced in declaration order by the widget renderer; later
4082    // overlays override earlier ones for any cells they cover.
4083    let boundaries: std::collections::BTreeSet<usize> = std::iter::once(0)
4084        .chain(std::iter::once(text.len()))
4085        .chain(
4086            normalized
4087                .inline_overlays
4088                .iter()
4089                .flat_map(|o| [o.start.min(text.len()), o.end.min(text.len())]),
4090        )
4091        .collect();
4092    let bounds: Vec<usize> = boundaries.into_iter().collect();
4093
4094    let mut spans: Vec<Span<'_>> = Vec::new();
4095    for win in bounds.windows(2) {
4096        let (a, b) = (win[0], win[1]);
4097        if a >= b {
4098            continue;
4099        }
4100        let slice = text[a..b].to_string();
4101        // Merge (don't replace) overlapping overlays so a later
4102        // overlay can override individual properties (bg, fg,
4103        // italic, …) without wiping the earlier overlay's other
4104        // properties. The text-input renderer relies on this:
4105        // the placeholder overlay sets fg + italic, then the
4106        // focused overlay sets bg only — without per-property
4107        // merge the focused-bg overlay would also clear the
4108        // placeholder's italic-dim styling, making placeholder
4109        // text indistinguishable from a typed value under focus.
4110        let mut style = base_style;
4111        for o in &normalized.inline_overlays {
4112            let os = o.start.min(text.len());
4113            let oe = o.end.min(text.len());
4114            if a >= os && b <= oe && oe > os {
4115                let resolved = Editor::resolve_overlay_style(&o.style, theme);
4116                if let Some(fg) = resolved.fg {
4117                    style = style.fg(fg);
4118                }
4119                if let Some(bg) = resolved.bg {
4120                    style = style.bg(bg);
4121                }
4122                // Ratatui `Style` carries add/sub modifier sets;
4123                // OR the additions in so subsequent overlays can
4124                // add italic / bold / etc. on top of the prior
4125                // overlay's modifiers.
4126                style = style.add_modifier(resolved.add_modifier);
4127                style = style.remove_modifier(resolved.sub_modifier);
4128            }
4129        }
4130        // Ensure a bg is set: ratatui will paint the slot with
4131        // the terminal's default bg otherwise, which doesn't
4132        // match the surrounding panel chrome.
4133        if style.bg.is_none() {
4134            style = style.bg(base_bg);
4135        }
4136        spans.push(Span::styled(slice, style));
4137    }
4138
4139    let line = Line::from(spans);
4140    let rect = ratatui::layout::Rect {
4141        x,
4142        y,
4143        width,
4144        height: 1,
4145    };
4146    frame.render_widget(Paragraph::new(line).style(base_style), rect);
4147}
4148
4149/// Translate a UTF-8 byte offset within a rendered line into a
4150/// display-column offset, walking codepoints with their Unicode
4151/// width. Used to place the hardware caret on the focused
4152/// TextInput's byte position.
4153fn byte_to_screen_col(text: &str, target_byte: usize) -> usize {
4154    use unicode_width::UnicodeWidthChar;
4155    let mut byte = 0;
4156    let mut col = 0usize;
4157    for ch in text.chars() {
4158        if byte >= target_byte {
4159            break;
4160        }
4161        col += UnicodeWidthChar::width(ch).unwrap_or(0);
4162        byte += ch.len_utf8();
4163    }
4164    col
4165}