Skip to main content

fresh/app/
render.rs

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