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