Skip to main content

fresh/app/
render.rs

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