use super::lsp_status::compose_lsp_status;
use super::*;
use crate::config::FileExplorerSide;

impl Editor {
    /// Render the topmost global popup at its computed area and register its
    /// click region in `global_popup_areas`. Shared by the generic
    /// global-popup slot and the workspace-trust modal band so the area math
    /// lives in exactly one place.
    fn render_top_global_popup(
        &mut self,
        frame: &mut Frame,
        size: ratatui::layout::Rect,
        theme: &crate::view::theme::Theme,
        hover_target: Option<&crate::app::HoverTarget>,
        // When false, compute + cache the popup area but draw no cells (the web
        // renders popups natively from `popups_view`). TUI passes `true`.
        draw: bool,
    ) {
        let Some(popup) = self.global_popups.top() else {
            return;
        };
        let top_idx = self.global_popups.all().len() - 1;
        let popup_area = popup.calculate_area(size, None);
        let desc_height = popup.description_height();
        let inner_area = if popup.bordered {
            ratatui::layout::Rect {
                x: popup_area.x + 1,
                y: popup_area.y + 1 + desc_height,
                width: popup_area.width.saturating_sub(2),
                height: popup_area.height.saturating_sub(2 + desc_height),
            }
        } else {
            ratatui::layout::Rect {
                x: popup_area.x,
                y: popup_area.y + desc_height,
                width: popup_area.width,
                height: popup_area.height.saturating_sub(desc_height),
            }
        };
        let num_items = match &popup.content {
            crate::view::popup::PopupContent::List { items, .. } => items.len(),
            _ => 0,
        };
        let scroll_offset = popup.scroll_offset;
        if draw {
            popup.render_with_hover(frame, popup_area, theme, hover_target);
        }
        self.active_chrome_mut().global_popup_areas.push((
            top_idx,
            popup_area,
            inner_area,
            scroll_offset,
            num_items,
        ));
    }

    /// Ask for another frame because plugin work was deferred out of this one.
    /// The drawing itself never blocks on the plugin lock — every hook site
    /// inside the draw uses `try_read` and skips on contention — so anything
    /// skipped has to be guaranteed a retry. (The pre-layout drain at the top
    /// of `render` does take the lock; it runs before any drawing, which is the
    /// point of doing it there.)
    fn request_plugin_render(&mut self) {
        #[cfg(feature = "plugins")]
        {
            self.plugin_render_requested = true;
        }
    }

    /// Render the editor to the terminal
    pub fn render(&mut self, frame: &mut Frame) {
        let _span = tracing::info_span!("render").entered();
        let size = frame.area();

        {
            let _s = tracing::info_span!("pre_layout_drain").entered();
            self.drain_pre_layout_plugin_commands();
        }

        for window in self.windows.values_mut() {
            window.sync_terminal_titles();
            window.enforce_terminal_grid_wrap();
        }

        // Carve a full-height left column for a docked floating panel
        // (e.g. the orchestrator dock) out of the screen *before* the
        // chrome lays itself out, so the menu bar, splits, and status
        // bar all sit to the dock's right. `chrome_area` is the region
        // the rest of `render` lays into; `dock_area` (if any) is
        // painted last alongside the centered-overlay path.
        let (dock_area, chrome_area) = self.compute_dock_split(size);

        // Let active animations snapshot the previous frame's buffer.
        // We can't read the live `frame.buffer_mut()` — ratatui resets it
        // before each draw — so the editor keeps a post-apply clone of
        // the last frame and hands it in.
        let previous_frame = self.last_rendered_frame.take();
        self.active_window_mut()
            .animations
            .capture_before_all(previous_frame.as_ref());
        self.last_rendered_frame = previous_frame;

        // Save frame dimensions for recompute_layout (used by macro replay)
        self.active_chrome_mut().last_frame.width = size.width;
        self.active_chrome_mut().last_frame.height = size.height;

        // Reset per-cell theme key map for this frame
        self.active_chrome_mut().reset_cell_theme_map();

        self.pre_sync_and_scroll_sync();

        // NOTE: Viewport sync with cursor is handled by split_rendering.rs which knows the
        // correct content area dimensions. Don't sync here with incorrect EditorState viewport size.

        {
            let _s = tracing::info_span!("request_semantic_ranges").entered();
            self.request_semantic_ranges_for_visible_splits();
        }

        {
            let _s = tracing::info_span!("prepare_visible_buffers").entered();
            self.prepare_visible_buffers_for_render();
        }

        // Refresh search highlights only during incremental search (when prompt is active)
        // After search is confirmed, overlays exist for ALL matches and shouldn't be overwritten
        let is_search_prompt_active = self.active_window().prompt.as_ref().is_some_and(|p| {
            matches!(
                p.prompt_type,
                PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch
            )
        });
        if is_search_prompt_active {
            if let Some(ref search_state) = self.active_window().search_state {
                let query = search_state.query.clone();
                self.update_search_highlights(&query);
            }
        }

        // Determine if we need to show search options bar.
        // These are stable for the whole frame: every plugin command was
        // dispatched before this point (pre-layout drain), and nothing after
        // it mutates prompt state mid-paint.
        let show_search_options = self.active_prompt_has_search_options();

        // Hide status bar when suggestions popup or file browser
        // popup is shown — those popups float just above the prompt
        // line, and a visible status bar wedged between them looks
        // wrong. Floating-overlay prompts (Live Grep, issue #1796)
        // are exempt because their suggestions live inside the
        // centred frame, not above the bottom row.
        let prompt_is_overlay = self
            .active_window()
            .prompt
            .as_ref()
            .is_some_and(|p| p.overlay);
        let has_suggestions = self
            .active_window()
            .prompt
            .as_ref()
            .is_some_and(|p| !p.suggestions.is_empty())
            && !prompt_is_overlay;
        let has_file_browser = self.active_window().prompt.as_ref().is_some_and(|p| {
            matches!(
                p.prompt_type,
                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
            )
        }) && self.active_window_mut().file_open_state.is_some();

        // Build main vertical layout: [menu_bar, main_content, status_bar, search_options, prompt_line]
        // Status bar is hidden when suggestions popup is shown
        // Search options bar is shown when in search prompt
        let main_chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints(vec![
                Constraint::Length(if self.active_window_mut().menu_bar_visible {
                    1
                } else {
                    0
                }), // Menu bar
                Constraint::Min(0), // Main content area
                Constraint::Length(
                    if !self.active_window_mut().status_bar_visible
                        || has_suggestions
                        || has_file_browser
                    {
                        0
                    } else {
                        1
                    },
                ), // Status bar (hidden when toggled off or with popups)
                Constraint::Length(if show_search_options { 1 } else { 0 }), // Search options bar
                Constraint::Length(
                    // Prompt line is auto-hidden when no prompt active.
                    // Overlay prompts (Live Grep, issue #1796) host the
                    // input row inside the centred frame, so the
                    // bottom row stays available for editor content
                    // rather than being reserved as dead space.
                    if (self.active_window_mut().prompt_line_visible
                        || self.active_window().prompt.is_some())
                        && !prompt_is_overlay
                    {
                        1
                    } else {
                        0
                    },
                ), // Prompt line
            ])
            .split(chrome_area);

        let menu_bar_area = main_chunks[0];
        let main_content_area = main_chunks[1];
        let status_bar_idx = 2;
        let search_options_idx = 3;
        let prompt_line_idx = 4;

        // Split the main content area into the explorer sidebar (when shown)
        // and the editor content area beside it. Painting the explorer is a
        // separate, composable step that draws only into the returned rect.
        let (editor_content_area, file_explorer_area) =
            self.split_file_explorer_area(main_content_area);
        if let Some(file_explorer_area) = file_explorer_area {
            self.render_file_explorer(frame, file_explorer_area);
        }

        // Note: Tabs are now rendered within each split by SplitRenderer

        // Trigger lines_changed hooks for newly visible lines in all visible buffers
        // This allows plugins to add overlays before rendering
        // Only lines that haven't been seen before are sent (batched for efficiency)
        // Use non-blocking hooks to avoid deadlock when actions are awaiting.
        //
        // `try_read` rather than `read`: a busy plugin thread must never be
        // able to stall the draw on a lock. Losing the race skips this frame's
        // hooks and requests another, so decorations arrive one frame later
        // instead of the whole frame arriving late.
        let plugins_active = self.plugin_manager.try_read().map(|pm| pm.is_active()).ok();
        if plugins_active.is_none() {
            self.request_plugin_render();
        }
        if plugins_active.unwrap_or(false) {
            let _s = tracing::info_span!("render_plugin_hooks").entered();
            let hooks_start = std::time::Instant::now();
            // Get visible buffers and their areas
            let visible_buffers = self
                .windows
                .get(&self.active_window)
                .and_then(|w| w.buffers.splits())
                .map(|(mgr, _)| mgr)
                .expect("active window must have a populated split layout")
                .get_visible_buffers(editor_content_area);

            let mut total_new_lines = 0usize;
            for (split_id, buffer_id, split_area) in visible_buffers {
                // Get viewport from SplitViewState (the authoritative source)
                let viewport_top_byte = self
                    .windows
                    .get(&self.active_window)
                    .and_then(|w| w.buffers.splits())
                    .map(|(_, vs)| vs)
                    .expect("active window must have a populated split layout")
                    .get(&split_id)
                    .map(|vs| vs.viewport.top_byte())
                    .unwrap_or(0);

                let __active_id = self.active_window;
                let __win = self
                    .windows
                    .get_mut(&__active_id)
                    .expect("active window must exist");
                // Take a disjoint mut borrow on `seen_byte_ranges` (a sibling
                // field on Window, not part of WindowBuffers) so the closure
                // below can update it alongside the buffer + view-state
                // mutations.
                let seen_ranges_for_win = &mut __win.seen_byte_ranges;
                let plugin_manager = &self.plugin_manager;
                let estimated_line_length = self.config.editor.estimated_line_length;
                let mut hooks_deferred = false;
                let added = __win
                    .buffers
                    .with_buffer_and_view_states(buffer_id, |state, _vs_map| {
                        // Both hooks go through `try_read`: the draw computes
                        // the payload and hands it off, but never waits on the
                        // plugin lock. On contention nothing is marked seen, so
                        // the same lines are offered again next frame — the
                        // payload is deferred, not lost.
                        let Ok(pm_guard) = plugin_manager.try_read() else {
                            hooks_deferred = true;
                            return 0;
                        };
                        // `render_start` has a tiny payload (just the
                        // buffer id) — fire unconditionally so third-party
                        // plugins listening for it still work.
                        pm_guard.run_hook(
                            "render_start",
                            crate::services::plugins::hooks::HookArgs::RenderStart { buffer_id },
                        );

                        let visible_count = split_area.height as usize;

                        let top_byte = viewport_top_byte;
                        let seen_byte_ranges = seen_ranges_for_win.entry(buffer_id).or_default();

                        let mut new_lines: Vec<crate::services::plugins::hooks::LineInfo> =
                            Vec::new();
                        let mut fresh_ranges: Vec<(usize, usize)> = Vec::new();
                        let mut line_number = state.buffer.get_line_number(top_byte);
                        let mut iter = state.buffer.line_iterator(top_byte, estimated_line_length);

                        for _ in 0..visible_count {
                            if let Some((line_start, line_content)) = iter.next_line() {
                                let byte_end = line_start + line_content.len();
                                let byte_range = (line_start, byte_end);

                                if !seen_byte_ranges.contains(&byte_range) {
                                    new_lines.push(crate::services::plugins::hooks::LineInfo {
                                        line_number,
                                        byte_start: line_start,
                                        byte_end,
                                        content: line_content,
                                    });
                                    fresh_ranges.push(byte_range);
                                }
                                line_number += 1;
                            } else {
                                break;
                            }
                        }

                        let count = new_lines.len();
                        if !new_lines.is_empty() {
                            pm_guard.run_hook(
                                "lines_changed",
                                crate::services::plugins::hooks::HookArgs::LinesChanged {
                                    buffer_id,
                                    lines: new_lines,
                                    epoch: state.buffer.version(),
                                },
                            );
                            for range in fresh_ranges {
                                seen_byte_ranges.insert(range);
                            }
                        }
                        count
                    })
                    .unwrap_or(0);
                if hooks_deferred {
                    self.request_plugin_render();
                }
                total_new_lines += added;
            }
            let hooks_elapsed = hooks_start.elapsed();
            tracing::trace!(
                new_lines = total_new_lines,
                elapsed_ms = hooks_elapsed.as_millis(),
                elapsed_us = hooks_elapsed.as_micros(),
                "lines_changed hooks total"
            );

            // Hook replies (AddOverlay, conceals) are NOT collected here.
            // The draw dispatches no plugin commands after this point: the
            // plugin thread answers the hooks above on its own time, the
            // arriving commands set `plugin_render_requested` via the tick
            // drain, and the decorations land on the next frame. One frame
            // of decoration latency is the fixed, universal price for a
            // frame that cannot tear and whose cost does not depend on how
            // much a plugin decided to emit mid-paint.
        }

        // Render editor content (same for both layouts)
        let lsp_waiting = !self.active_window().pending_completion_requests.is_empty()
            || self
                .active_window()
                .pending_goto_definition_request
                .is_some();

        // Hide the hardware cursor when menu is open, file explorer is focused, terminal mode,
        // or settings UI is open
        // (the file explorer will set its own cursor position when focused)
        // (terminal mode renders its own cursor via the terminal emulator)
        // (settings UI is a modal that doesn't need the editor cursor)
        // This also causes visual cursor indicators in the editor to be dimmed
        let settings_visible = self.settings_state.as_ref().is_some_and(|s| s.visible);
        let hide_cursor = self.menu_state.active_menu.is_some()
            || self.active_window_mut().key_context == KeyContext::FileExplorer
            || self.active_window().focused_terminal_live()
            || self.dock.as_ref().is_some_and(|d| d.focused)
            || settings_visible
            || self.keybinding_editor.is_some()
            // A dormant remote session's shell renders as a placeholder
            // page (no editable buffer), so no text cursor either.
            || self.dormant_remote.contains_key(&self.active_window);

        // Convert HoverTarget to tab hover info for rendering
        let hovered_tab = match &self.active_window_mut().mouse_state.hover_target {
            Some(HoverTarget::TabName(target, split_id)) => Some((*target, *split_id, false)),
            Some(HoverTarget::TabCloseButton(target, split_id)) => Some((*target, *split_id, true)),
            _ => None,
        };

        // Get hovered close split button
        let hovered_close_split = match &self.active_window_mut().mouse_state.hover_target {
            Some(HoverTarget::CloseSplitButton(split_id)) => Some(*split_id),
            _ => None,
        };

        // Get hovered maximize split button
        let hovered_maximize_split = match &self.active_window_mut().mouse_state.hover_target {
            Some(HoverTarget::MaximizeSplitButton(split_id)) => Some(*split_id),
            _ => None,
        };

        let is_maximized = self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.splits())
            .map(|(mgr, _)| mgr)
            .expect("active window must have a populated split layout")
            .is_maximized();

        // The active split's buffer renderer records where the hardware
        // cursor *wants* to appear here; we only commit it to the frame at
        // the very end of this draw pass, after popups have been rendered,
        // so a popup covering the cursor cell causes the cursor to be
        // hidden (otherwise the hardware caret would bleed through the
        // popup).
        let mut pending_hardware_cursor: Option<(u16, u16)> = None;

        let _content_span = tracing::info_span!("render_content").entered();
        // Web renders the tab bar natively from `tab_bar_view`; skip painting it
        // to cells (its TabLayout is still computed). Panes always draw.
        let split_draw_tab_bar = !self.suppress_chrome_cells;
        // Bundle the immutable editor render settings. Built before the
        // `&mut self.windows` borrow below; it only borrows `self.config`,
        // so the two coexist.
        let cfg = crate::view::ui::EditorRenderConfig::new(
            &self.config.editor,
            self.background_fade,
            self.software_cursor_only,
        );
        // `cfg` is captured by the render closure; the theme read-guard and the
        // `RenderStyle` wrapping it are built *inside* the closure so the
        // `self.theme` borrow is released before the post-render `&mut self`
        // chrome updates.
        // Take a single mutable borrow on the active window's splits and
        // split it into (&SplitManager, &mut HashMap<...>) — Rust can
        // destructure the tuple, but we can't make two separate
        // `windows.get`/`windows.get_mut` calls in the same expression.
        let active_window_id = self.active_window;
        // Take one &mut on the active window. Split-borrow into
        // buffers (mut), split_mgr (immutable view of mgr), and
        // split_view_states (mut) — all disjoint sub-fields.
        let __win = self
            .windows
            .get_mut(&active_window_id)
            .expect("active window must exist");
        let __metadata_ref = &__win.buffer_metadata;
        // Copy out the preview buffer id (the single source of truth) so the
        // tab renderer can style the "(preview)" tab without holding a borrow
        // of `__win` across the `with_all_mut` closure below.
        let __preview_buffer = __win.preview.map(|(_, b)| b);
        // The splits currently showing a terminal in read-only scrollback.
        // Per-split (not a window flag), so two splits on the same terminal can
        // differ — one live, one scrollback (fresh#2595). Computed here, before
        // the disjoint mutable sub-field borrows below take effect.
        let __scrollback_view_splits: std::collections::HashSet<crate::model::event::LeafId> =
            __win
                .buffers
                .splits()
                .map(|(_, vs_map)| {
                    vs_map
                        .iter()
                        .filter(|(leaf, svs)| {
                            __win.split_terminal_scrollback(**leaf, svs.active_buffer)
                        })
                        .map(|(leaf, _)| *leaf)
                        .collect()
                })
                .unwrap_or_default();
        let __event_logs_mut = &mut __win.event_logs;
        let __grouped_ref = &__win.grouped_subtrees;
        let __composite_buffers_mut = &mut __win.composite_buffers;
        let __composite_view_states_mut = &mut __win.composite_view_states;
        let __cell_theme_map_mut = &mut __win.chrome_layout.cell_theme_map;
        let __tab_bar_visible = __win.tab_bar_visible;
        let (
            split_areas,
            tab_layouts,
            close_split_areas,
            maximize_split_areas,
            view_line_mappings,
            horizontal_scrollbar_areas,
            grouped_separator_areas,
        ) = __win
            .buffers
            .with_all_mut(|__buffers_mut, __mgr, __vs_map| {
                // Built here so the `self.theme` read-guard lives only for the
                // render call, not the later `&mut self` chrome updates.
                let theme_guard = self.theme.read().unwrap();
                let style = crate::view::ui::RenderStyle {
                    theme: &theme_guard,
                    ansi_background: self.ansi_background.as_ref(),
                    cfg,
                };
                SplitRenderer::render_content(
                    frame.buffer_mut(),
                    editor_content_area,
                    &*__mgr,
                    __buffers_mut,
                    __metadata_ref,
                    __preview_buffer,
                    __event_logs_mut,
                    __composite_buffers_mut,
                    __composite_view_states_mut,
                    style,
                    lsp_waiting,
                    Some(__vs_map),
                    __grouped_ref,
                    hide_cursor,
                    hovered_tab,
                    hovered_close_split,
                    hovered_maximize_split,
                    is_maximized,
                    __tab_bar_visible,
                    self.session_mode || !self.software_cursor_only,
                    &__scrollback_view_splits,
                    __cell_theme_map_mut,
                    size.width,
                    &mut pending_hardware_cursor,
                    split_draw_tab_bar,
                )
            })
            .expect("active window must have a populated split layout");

        drop(_content_span);
        let _post_content_span = tracing::info_span!("render_post_content").entered();

        // Cursor-jump animation: compare the cursor's screen position to
        // the prior frame and animate either when the cursor crossed split
        // panes or moved more than two rows within the same pane. The
        // trail crosses pane separators when the jump is across splits —
        // that's the intended "follow the focus" cue.
        let active_split = self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.splits())
            .map(|(mgr, _)| mgr)
            .expect("active window must have a populated split layout")
            .active_split();
        self.maybe_start_cursor_jump_animation(pending_hardware_cursor, active_split);

        // A dormant remote session's shell shows a placeholder page instead
        // of an (empty, uneditable) buffer: nothing can be shown or edited
        // until the backend connects, so painting a tab bar and a "[No
        // Name]" scratch buffer misrepresents the state. Painted over the
        // content area after the split renderer so it composes with the
        // chrome (menu, dock, status bar) without forking the render flow.
        if self.dormant_remote.contains_key(&self.active_window) {
            self.render_dormant_shell_page(frame, editor_content_area);
        }

        // Detect viewport changes and fire hooks
        // Compare against previous frame's viewport state (stored in self.active_window().previous_viewports)
        // This correctly detects changes from scroll events that happen before render()
        //
        // `try_read` again: never wait on the plugin lock inside the draw.
        // When the lock is busy the `previous_viewports` update below is
        // skipped too, so the same change is re-detected next frame rather
        // than being silently swallowed.
        let mut viewport_hooks_deferred = false;
        let viewport_plugins_active = match self.plugin_manager.try_read() {
            Ok(pm) => pm.is_active(),
            Err(_) => {
                viewport_hooks_deferred = true;
                false
            }
        };
        if viewport_plugins_active {
            for (split_id, view_state) in self
                .windows
                .get(&self.active_window)
                .and_then(|w| w.buffers.splits())
                .map(|(_, vs)| vs)
                .expect("active window must have a populated split layout")
            {
                let current = (
                    view_state.viewport.top_byte(),
                    view_state.viewport.width,
                    view_state.viewport.height,
                );
                // Compare against previous frame's state
                // Skip new splits (None case) - only fire hooks for established splits
                // This matches the original behavior where hooks only fire for splits
                // that existed at the start of render
                let (changed, previous) =
                    match self.active_window().previous_viewports.get(split_id) {
                        Some(previous) => (*previous != current, Some(*previous)),
                        None => (false, None), // Skip new splits until they're established
                    };
                tracing::trace!(
                    "viewport_changed check: split={:?} current={:?} previous={:?} changed={}",
                    split_id,
                    current,
                    previous,
                    changed
                );
                if changed {
                    if let Some(buffer_id) = self
                        .windows
                        .get(&self.active_window)
                        .and_then(|w| w.buffers.splits())
                        .map(|(mgr, _)| mgr)
                        .expect("active window must have a populated split layout")
                        .get_buffer_id((*split_id).into())
                    {
                        // Compute top_line if line info is available
                        let top_line = self
                            .windows
                            .get(&self.active_window)
                            .map(|w| &w.buffers)
                            .expect("active window present")
                            .get(&buffer_id)
                            .and_then(|state| {
                                if state.buffer.line_count().is_some() {
                                    Some(
                                        state
                                            .buffer
                                            .get_line_number(view_state.viewport.top_byte()),
                                    )
                                } else {
                                    None
                                }
                            });
                        tracing::debug!(
                            "Firing viewport_changed hook: split={:?} buffer={:?} top_byte={} top_line={:?}",
                            split_id,
                            buffer_id,
                            view_state.viewport.top_byte(),
                            top_line
                        );
                        let Ok(pm) = self.plugin_manager.try_read() else {
                            viewport_hooks_deferred = true;
                            continue;
                        };
                        pm.run_hook(
                            "viewport_changed",
                            crate::services::plugins::hooks::HookArgs::ViewportChanged {
                                split_id: (*split_id).into(),
                                buffer_id,
                                top_byte: view_state.viewport.top_byte(),
                                top_line,
                                width: view_state.viewport.width,
                                height: view_state.viewport.height,
                            },
                        );
                    }
                }
            }
        }

        // A hook we couldn't deliver this frame must stay pending: leaving
        // `previous_viewports` untouched is what makes the change re-detected
        // next frame instead of vanishing.
        if viewport_hooks_deferred {
            self.request_plugin_render();
        }

        // Update previous_viewports for next frame's comparison.
        // Take both `previous_viewports` and the split view-states from
        // the same `__win` borrow so the iterator and the inserts share
        // a single mutable borrow on `self.windows`.
        let skip_viewport_snapshot = viewport_hooks_deferred;
        let __vp_win = self
            .windows
            .get_mut(&self.active_window)
            .expect("active window present");
        if !skip_viewport_snapshot {
            __vp_win.previous_viewports.clear();
        }
        let (_, __vp_vs_map) = __vp_win
            .buffers
            .splits()
            .expect("active window must have a populated split layout");
        let snapshot: Vec<(LeafId, (usize, u16, u16))> = __vp_vs_map
            .iter()
            .map(|(split_id, view_state)| {
                (
                    *split_id,
                    (
                        view_state.viewport.top_byte(),
                        view_state.viewport.width,
                        view_state.viewport.height,
                    ),
                )
            })
            .collect();
        if !skip_viewport_snapshot {
            for (split_id, vp) in snapshot {
                __vp_win.previous_viewports.insert(split_id, vp);
            }
        }

        // Render terminal content on top of split content for terminal buffers.
        // Active-window path: cursor blinks normally when terminal_mode is on.
        self.active_window()
            .render_terminal_splits(frame, &split_areas, true);

        self.active_layout_mut().split_areas = split_areas;
        self.active_layout_mut().horizontal_scrollbar_areas = horizontal_scrollbar_areas;
        self.active_layout_mut().tab_layouts = tab_layouts;
        self.active_layout_mut().close_split_areas = close_split_areas;
        self.active_layout_mut().maximize_split_areas = maximize_split_areas;
        self.active_layout_mut().view_line_mappings = view_line_mappings;

        // Widget panels mounted into splits render through the ordinary
        // buffer pipeline, which knows nothing about widget geometry —
        // paint their overflowing lists' scrollbars on top.
        self.render_split_widget_panel_scrollbars(frame);

        // Promote any deferred virtual-buffer animations whose Rect is now
        // known. Done here (after split_areas is recomputed, before
        // apply_all runs at the end of render) so the first frame of the
        // effect lands on the same paint that made the buffer visible.
        self.drain_pending_vb_animations();
        let mut separator_areas = self
            .split_manager_mut()
            .get_separators_with_ids(editor_content_area);
        // Grouped subtrees live in a side-map outside the main split tree, so
        // their inner separators are not visited by `get_separators_with_ids`
        // above. The renderer collected them (using the same content rect it
        // drew them at) — merge so clicks on those rendered columns register.
        separator_areas.extend(grouped_separator_areas);
        self.active_layout_mut().separator_areas = separator_areas;
        self.active_layout_mut().editor_content_area = Some(editor_content_area);

        // Render hover highlights for separators and scrollbars
        self.render_hover_highlights(frame);

        // Initialize popup/suggestion layout state (rendered after status bar below)
        self.active_chrome_mut().suggestions_area = None;
        self.active_chrome_mut().suggestions_outer_area = None;
        self.active_chrome_mut().prompt_results_area = None;
        self.active_chrome_mut().prompt_preview_area = None;
        self.active_window_mut().file_browser_layout = None;

        // Clone all immutable values before the mutable borrow
        let display_name = self
            .active_window()
            .buffer_metadata
            .get(&self.active_buffer())
            .map(|m| m.display_name.clone())
            .unwrap_or_else(|| "[No Name]".to_string());

        // Reflect the active buffer in the terminal window/tab title. Only
        // writes when the title actually changes so we don't flood stdout
        // with OSC sequences every frame.
        self.update_terminal_title(&display_name);

        let prompt = self.active_window().prompt.clone();
        let theme = self.theme.read().unwrap().clone();
        let keybindings_cloned = self.keybindings.read().unwrap().clone(); // Clone the keybindings

        // Status bar (hidden when toggled off, or when a suggestions/file-
        // browser popup covers the bottom row).
        self.render_status_bar_row(
            frame,
            main_chunks[status_bar_idx],
            &display_name,
            &theme,
            &keybindings_cloned,
            has_suggestions,
            has_file_browser,
        );

        // Search-options bar (case / whole-word / regex / confirm toggles),
        // shown only while a search-style prompt is active.
        self.render_search_options_bar(
            frame,
            main_chunks[search_options_idx],
            show_search_options,
            &theme,
            &keybindings_cloned,
        );

        // Prompt input line (non-overlay prompts only).
        self.render_prompt_line(frame, main_chunks[prompt_line_idx], &prompt, &theme);

        // Float-overlay preview: load the selected match's file (if
        // the file changed) and seed the phantom leaf's cursor before
        // the renderer reaches it. Done before render_prompt_popups
        // because that path immediately needs the leaf's view state.
        if self
            .active_window()
            .prompt
            .as_ref()
            .is_some_and(|p| p.overlay)
        {
            self.prepare_overlay_preview();
        }

        // Render file browser popup or suggestions popup AFTER status bar + prompt,
        // so they overlay on top of both (fixes bottom border being overwritten by status bar)
        self.render_prompt_popups(frame, main_chunks[prompt_line_idx], chrome_area);

        // Render popups from the active buffer state
        // Clone theme to avoid borrow checker issues with active_state_mut()
        let theme_clone = self.theme.read().unwrap().clone();
        let hover_target = self.active_window_mut().mouse_state.hover_target.clone();

        // Cursor-anchored buffer popups (completion, hover, signature help):
        // recompute their areas for hit-testing and paint them.
        self.render_buffer_popups(frame, chrome_area, &theme_clone, &hover_target);

        // Render editor-level popups (e.g. plugin action popups) on top of any
        // buffer content so they stay visible across buffer switches and over
        // virtual buffers (Dashboard, diagnostics) that own the whole split.
        // These don't need cursor-relative positioning — they all use absolute
        // positions like BottomRight or Centered.
        //
        // Queue semantics: concurrent action popups stack in `global_popups`,
        // but only the top one renders & receives input. Deeper popups
        // surface as the top is resolved — the alternative (drawing all at
        // the same BottomRight slot) makes them illegible.
        self.active_chrome_mut().global_popup_areas.clear();
        // The workspace-trust prompt is a blocking modal: it renders later in
        // the dedicated modal z-band (alongside settings / wizard) on a dimmed
        // backdrop, so it can't be lost amongst dashboard/explorer chrome.
        // Everything else on the global stack renders here, above buffer content.
        let top_is_trust_modal = self.global_popups.top().is_some_and(|p| {
            matches!(
                p.resolver,
                crate::view::popup::PopupResolver::WorkspaceTrust
            )
        });
        if !top_is_trust_modal {
            // Global popups render within the chrome area (right of a
            // left dock) so corner/centred popups don't overrun it.
            let draw_global_popup = !self.suppress_chrome_cells;
            self.render_top_global_popup(
                frame,
                chrome_area,
                &theme_clone,
                hover_target.as_ref(),
                draw_global_popup,
            );
        }

        // Render menu bar last so dropdown appears on top of all other content
        // Update menu context with current editor state
        self.update_menu_context();

        // The full-screen modals (settings, calibration wizard, keybinding
        // editor, event-debug dialog) and the blocking workspace-trust prompt
        // each dim the *entire* frame — the dock included — and centre in the
        // full window, so they are rendered at the very end of this method,
        // after the dock and floating panels, rather than here, where the
        // dock's later pass would overpaint their left edge. See the bottom of
        // `render` and `render_panels_and_modals`.

        // Menu bar, drawn last so its dropdowns sit above all other content.
        self.render_menu_bar(frame, menu_bar_area);

        // Tab / file-explorer / new-tab context menus (TUI cell drawing only).
        self.render_context_menus(frame);

        // Chrome theme-key provenance (status bar, menu, tabs, file explorer,
        // scrollbars) is now recorded during each region's own paint.

        // Render tab drag drop zone overlay if dragging a tab
        let drag_state_clone = self.active_window().mouse_state.dragging_tab.clone();
        if let Some(ref drag_state) = drag_state_clone {
            if drag_state.is_dragging() {
                self.render_tab_drop_zone(frame, drag_state);
            }
        }

        // Software mouse cursor (GPM) and keyboard-capture dimming — both
        // read already-painted cells, so they run after the main draw.
        self.render_software_cursor_and_capture(frame, size);

        // Commit the active-split hardware cursor (deferred since
        // `render_content`) unless a popup has been drawn over that cell.
        // Ratatui draws the hardware caret on top of every cell, so a
        // popup cannot hide the cursor by painting cells — the only way
        // to hide it is to leave `Frame::cursor_position` as `None`, which
        // triggers `Terminal::hide_cursor` at the end of the draw.
        //
        // When a prompt is active the prompt renderer already placed the
        // caret on the prompt line via `frame.set_cursor_position`; don't
        // override it with the (now-irrelevant) buffer cursor.
        if let Some((cx, cy)) = pending_hardware_cursor {
            if self.active_window().prompt.is_none() && !self.cursor_obscured_by_overlay(cx, cy) {
                frame.set_cursor_position((cx, cy));
            }
        }

        // Frame-buffer animations run after the main draw so they mutate the
        // final paint.
        self.active_window_mut()
            .animations
            .apply_all(frame.buffer_mut());

        // Keep the post-apply paint so the next frame's effects can push
        // it out of view. Cloned because ratatui resets the current
        // buffer before the next draw.
        self.last_rendered_frame = Some(frame.buffer_mut().clone());

        // Dock, full-screen modals, floating panel, theme-info popup, and the
        // workspace-trust modal — the topmost layers, drawn above
        // prompts/popups/animations.
        self.render_panels_and_modals(
            frame,
            size,
            chrome_area,
            dock_area,
            top_is_trust_modal,
            &theme_clone,
        );

        // Convert all colors for terminal capability (256/16 color fallback).
        // Dead last, so the layers painted above — dock, full-screen modals,
        // animations — go through the fallback too instead of emitting
        // truecolor SGR on a terminal that cannot render it.
        crate::view::color_support::convert_buffer_colors(
            frame.buffer_mut(),
            self.color_capability,
        );
    }

    /// Render the search-options bar into `area` when `show_search_options`
    /// is set (a search-style prompt is active), or clear its cached layout
    /// otherwise.
    fn render_search_options_bar(
        &mut self,
        frame: &mut Frame,
        area: ratatui::layout::Rect,
        show_search_options: bool,
        theme: &crate::view::theme::Theme,
        keybindings: &crate::input::keybindings::KeybindingResolver,
    ) {
        if show_search_options {
            // Show "Confirm" option only in replace modes
            let confirm_each = self.active_window().prompt.as_ref().and_then(|p| {
                if matches!(
                    p.prompt_type,
                    PromptType::ReplaceSearch
                        | PromptType::Replace { .. }
                        | PromptType::QueryReplaceSearch
                        | PromptType::QueryReplace { .. }
                ) {
                    Some(self.active_window().search_confirm_each)
                } else {
                    None
                }
            });

            // Determine hover state for search options
            use crate::view::ui::status_bar::SearchOptionsHover;
            let search_options_hover = match &self.active_window().mouse_state.hover_target {
                Some(HoverTarget::SearchOptionCaseSensitive) => SearchOptionsHover::CaseSensitive,
                Some(HoverTarget::SearchOptionWholeWord) => SearchOptionsHover::WholeWord,
                Some(HoverTarget::SearchOptionRegex) => SearchOptionsHover::Regex,
                Some(HoverTarget::SearchOptionConfirmEach) => SearchOptionsHover::ConfirmEach,
                _ => SearchOptionsHover::None,
            };

            let search_options_layout = StatusBarRenderer::render_search_options(
                frame,
                area,
                self.active_window().search_case_sensitive,
                self.active_window().search_whole_word,
                self.active_window().search_use_regex,
                confirm_each,
                theme,
                keybindings,
                search_options_hover,
            );
            self.active_chrome_mut().search_options_layout = Some(search_options_layout);
        } else {
            self.active_chrome_mut().search_options_layout = None;
        }
    }

    /// Render the bottom prompt input line into `area`. Overlay prompts (e.g.
    /// Live Grep) paint their own input row inside their centred frame and so
    /// skip this; file/folder open prompts use a path-colorising renderer.
    fn render_prompt_line(
        &mut self,
        frame: &mut Frame,
        area: ratatui::layout::Rect,
        prompt: &Option<crate::view::prompt::Prompt>,
        theme: &crate::view::theme::Theme,
    ) {
        if let Some(prompt) = prompt {
            if !prompt.overlay {
                // Use specialized renderer for file/folder open prompt to show colorized path
                if matches!(
                    prompt.prompt_type,
                    crate::view::prompt::PromptType::OpenFile
                        | crate::view::prompt::PromptType::SwitchProject
                ) {
                    if let Some(file_open_state) = &self.active_window().file_open_state {
                        StatusBarRenderer::render_file_open_prompt(
                            frame,
                            area,
                            prompt,
                            file_open_state,
                            theme,
                        );
                    } else {
                        StatusBarRenderer::render_prompt(frame, area, prompt, theme);
                    }
                } else {
                    StatusBarRenderer::render_prompt(frame, area, prompt, theme);
                }
            }
        }
    }

    /// Recompute the on-screen areas of the active buffer's cursor-anchored
    /// popups (completion / hover / signature help), cache them for mouse
    /// hit-testing, and paint them. `theme_clone` and `hover_target` are
    /// passed in because `render` reuses them for the global-popup pass.
    fn render_buffer_popups(
        &mut self,
        frame: &mut Frame,
        chrome_area: ratatui::layout::Rect,
        theme_clone: &crate::view::theme::Theme,
        hover_target: &Option<HoverTarget>,
    ) {
        // Clear popup areas and recalculate
        self.active_chrome_mut().popup_areas.clear();

        // Collect popup information without holding a mutable borrow
        let popup_info: Vec<_> = {
            // Get viewport from active split's SplitViewState
            let active_split = self
                .windows
                .get(&self.active_window)
                .and_then(|w| w.buffers.splits())
                .map(|(mgr, _)| mgr)
                .expect("active window must have a populated split layout")
                .active_split();
            let viewport = self
                .windows
                .get(&self.active_window)
                .and_then(|w| w.buffers.splits())
                .map(|(_, vs)| vs)
                .expect("active window must have a populated split layout")
                .get(&active_split)
                .map(|vs| vs.viewport.clone());

            // Get the content_rect for the active split from the cached layout.
            // This is the absolute screen rect (already accounts for file explorer,
            // tab bar, scrollbars, etc.). The gutter is rendered inside this rect,
            // so we add gutter_width to get the text content origin.
            let content_rect = self
                .active_layout()
                .split_areas
                .iter()
                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
                .map(|(_, _, rect, _, _, _)| *rect);

            let primary_cursor = self
                .windows
                .get(&self.active_window)
                .and_then(|w| w.buffers.splits())
                .map(|(_, vs)| vs)
                .expect("active window must have a populated split layout")
                .get(&active_split)
                .map(|vs| *vs.cursors.primary());
            let state = self.active_state_mut();
            if state.popups.is_visible() {
                // Get the primary cursor position for popup positioning
                let primary_cursor =
                    primary_cursor.unwrap_or_else(|| crate::model::cursor::Cursor::new(0));

                // Compute gutter width so we know where text content starts
                let gutter_width = viewport
                    .as_ref()
                    .map(|vp| vp.gutter_width(&state.buffer) as u16)
                    .unwrap_or(0);

                let cursor_screen_pos = viewport
                    .as_ref()
                    .map(|vp| vp.cursor_screen_position(&mut state.buffer, &primary_cursor))
                    .unwrap_or((0, 0));

                // For completion popups, compute the word-start screen position so
                // the popup aligns with the beginning of the word being completed,
                // not the current cursor position.
                let word_start_screen_pos = {
                    use crate::primitives::word_navigation::find_completion_word_start;
                    let word_start =
                        find_completion_word_start(&state.buffer, primary_cursor.position);
                    let word_start_cursor = crate::model::cursor::Cursor::new(word_start);
                    viewport
                        .as_ref()
                        .map(|vp| vp.cursor_screen_position(&mut state.buffer, &word_start_cursor))
                        .unwrap_or((0, 0))
                };

                // Use content_rect as the single source of truth for the text
                // content area origin. content_rect.x is the split's left edge
                // (already past the file explorer), content_rect.y is below the
                // tab bar. Adding gutter_width gives us the text content start.
                let (base_x, base_y) = content_rect
                    .map(|r| (r.x + gutter_width, r.y))
                    .unwrap_or((gutter_width, 1));

                let cursor_screen_pos =
                    (cursor_screen_pos.0 + base_x, cursor_screen_pos.1 + base_y);
                let word_start_screen_pos = (
                    word_start_screen_pos.0 + base_x,
                    word_start_screen_pos.1 + base_y,
                );

                // Collect popup data
                state
                    .popups
                    .all()
                    .iter()
                    .enumerate()
                    .map(|(popup_idx, popup)| {
                        // Use word-start x for completion popups, cursor x for others
                        let popup_pos = if popup.kind == crate::view::popup::PopupKind::Completion {
                            (word_start_screen_pos.0, cursor_screen_pos.1)
                        } else {
                            cursor_screen_pos
                        };
                        // Clamp within the chrome area (right of a left
                        // dock) so a cursor-anchored popup near the left
                        // edge can't extend into the dock column.
                        let popup_area = popup.calculate_area(chrome_area, Some(popup_pos));

                        // Track popup area for mouse hit testing
                        // Account for description height when calculating the list item area
                        let desc_height = popup.description_height();
                        let inner_area = if popup.bordered {
                            ratatui::layout::Rect {
                                x: popup_area.x + 1,
                                y: popup_area.y + 1 + desc_height,
                                width: popup_area.width.saturating_sub(2),
                                height: popup_area.height.saturating_sub(2 + desc_height),
                            }
                        } else {
                            ratatui::layout::Rect {
                                x: popup_area.x,
                                y: popup_area.y + desc_height,
                                width: popup_area.width,
                                height: popup_area.height.saturating_sub(desc_height),
                            }
                        };

                        let num_items = match &popup.content {
                            crate::view::popup::PopupContent::List { items, .. } => items.len(),
                            _ => 0,
                        };

                        // Calculate total content lines and scrollbar rect
                        let total_lines = popup.item_count();
                        let visible_lines = inner_area.height as usize;
                        let scrollbar_rect = if total_lines > visible_lines && inner_area.width > 2
                        {
                            Some(ratatui::layout::Rect {
                                x: inner_area.x + inner_area.width - 1,
                                y: inner_area.y,
                                width: 1,
                                height: inner_area.height,
                            })
                        } else {
                            None
                        };

                        (
                            popup_idx,
                            popup_area,
                            inner_area,
                            popup.scroll_offset,
                            num_items,
                            scrollbar_rect,
                            total_lines,
                        )
                    })
                    .collect()
            } else {
                Vec::new()
            }
        };

        // Store popup areas for mouse hit testing
        self.active_chrome_mut().popup_areas = popup_info.clone();

        // Now render popups (cells only when this frontend draws chrome itself;
        // the web renders them natively from `popups_view`, but the area cache
        // above is always populated for hit-routing).
        let draw_popups = !self.suppress_chrome_cells;
        let state = self.active_state_mut();
        if draw_popups && state.popups.is_visible() {
            for (popup_idx, popup) in state.popups.all().iter().enumerate() {
                if let Some((_, popup_area, _, _, _, _, _)) = popup_info.get(popup_idx) {
                    popup.render_with_hover(frame, *popup_area, theme_clone, hover_target.as_ref());
                }
            }
        }
    }

    /// Draw the software mouse cursor (GPM, which can't paint its own caret on
    /// the alt-screen) and the keyboard-capture dimming. Both read cells that
    /// the main draw already painted, so they run near the end of `render`.
    fn render_software_cursor_and_capture(
        &mut self,
        frame: &mut Frame,
        size: ratatui::layout::Rect,
    ) {
        // Render software mouse cursor when GPM is active
        // GPM can't draw its cursor on the alternate screen buffer used by TUI apps,
        // so we draw our own cursor at the tracked mouse position.
        // This must happen LAST in the render flow so we can read the already-rendered
        // cell content and invert it.
        if self.active_window().gpm_active {
            if let Some((col, row)) = self.active_window().mouse_cursor_position {
                use ratatui::style::Modifier;

                // Only render if within screen bounds
                if col < size.width && row < size.height {
                    // Get the cell at this position and add REVERSED modifier to invert colors
                    let buf = frame.buffer_mut();
                    if let Some(cell) = buf.cell_mut((col, row)) {
                        cell.set_style(cell.style().add_modifier(Modifier::REVERSED));
                    }
                }
            }
        }

        // When keyboard capture mode is active, dim all UI elements outside the terminal
        // to visually indicate that focus is exclusively on the terminal
        if self.active_window().keyboard_capture && self.active_window().focused_terminal_live() {
            // Find the active split's content area
            let active_split = self
                .windows
                .get(&self.active_window)
                .and_then(|w| w.buffers.splits())
                .map(|(mgr, _)| mgr)
                .expect("active window must have a populated split layout")
                .active_split();
            let active_split_area = self
                .active_layout()
                .split_areas
                .iter()
                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
                .map(|(_, _, content_rect, _, _, _)| *content_rect);

            if let Some(terminal_area) = active_split_area {
                self.apply_keyboard_capture_dimming(frame, terminal_area);
            }
        }
    }

    /// Render the topmost layers: the dock and floating widget panel (each in
    /// its own slot), the full-screen modals (settings, keybinding editor,
    /// …), the theme-info popup, and the blocking workspace-trust modal.
    /// Drawn after every other layer so they sit on top.
    fn render_panels_and_modals(
        &mut self,
        frame: &mut Frame,
        size: ratatui::layout::Rect,
        chrome_area: ratatui::layout::Rect,
        dock_area: Option<ratatui::layout::Rect>,
        top_is_trust_modal: bool,
        theme_clone: &crate::view::theme::Theme,
    ) {
        // Panels are drawn last so they sit above every other layer
        // (prompts, popups, animations). The two slots are independent:
        // the dock paints into its carved column (`dock_area`); a
        // centered modal paints over the whole frame (dimmed). Draw the
        // dock first so a centered modal sits visually above it.
        if let Some(dock) = dock_area {
            if self.dock.is_some() {
                self.render_floating_widget_panel(frame, dock, super::PanelSlot::Dock);
            }
        }

        // Settings / calibration-wizard / keybinding-editor / event-debug —
        // full-screen modals. They get the whole frame (`size`), not the
        // chrome region right of the dock: each dims everything behind it,
        // the dock included, and centres in the full window. Drawn here,
        // after the dock's own pass, so the dock cannot overpaint the
        // modal's left edge.
        self.render_modal_overlays(frame, size);

        // The theme-info popup (Ctrl+Right-Click) anchors to an absolute
        // screen cell that may sit over the dock column, so draw it after
        // the dock — otherwise the dock paints over it and its "Open in
        // Theme Editor" button is hidden and unclickable.
        // Web renders the theme-info popup natively from `aux_modals_view`.
        if !self.suppress_chrome_cells {
            self.render_theme_info_popup(frame);
        }

        if self.floating_widget_panel.is_some() {
            // A `fullscreen` modal paints over the whole frame, covering the
            // dock; otherwise it lays into `chrome_area` beside the dock.
            // The orchestrator's global modals (control room, New-Session
            // form) opt into fullscreen so they're not cramped into the
            // narrow region right of their own dock.
            let fullscreen = self
                .floating_widget_panel
                .as_ref()
                .map(|f| f.fullscreen)
                .unwrap_or(false);
            // An anchored context-menu popup is unobtrusive: it neither
            // dims the dock nor confines itself to `chrome_area` (its
            // anchor is an absolute screen cell that may sit over the
            // dock). Treat it like a non-dimming, full-frame placement.
            let is_anchored = matches!(
                self.floating_widget_panel.as_ref().map(|f| f.placement),
                Some(super::PanelPlacement::Anchored { .. })
            );
            // A centered modal makes the *whole* UI a passive, dimmed
            // background — the dock included. The dock was drawn above at
            // full brightness. A beside-dock modal only dims `chrome_area`,
            // so dim the dock column explicitly here; a fullscreen modal
            // dims the whole frame itself (its own `apply_dimming_excluding`
            // runs over the full area below), so skip the redundant pass.
            // Either way the dock is blurred + input-inaccessible while a
            // modal is up (the host blurs it on mount and the modal swallows
            // keys/clicks/wheel), so dimming it makes that passivity visible
            // rather than leaving it looking live beside the dialog.
            if !fullscreen && !is_anchored {
                if let Some(dock) = dock_area {
                    if self.dock.is_some() {
                        crate::view::dimming::apply_dimming(frame, dock);
                    }
                }
            }
            // Render the centered modal within `chrome_area` (the region to
            // the right of a left dock) rather than the whole frame, so it
            // sits beside the dock and dims only the chrome instead of
            // painting over the dock column. When no dock is up
            // `chrome_area` is the whole frame, so this is unchanged for the
            // common case. This is what lets a plugin's Open picker coexist
            // with the dock — mirroring the settings / keybinding-editor
            // modals, which already lay into `chrome_area`. A `fullscreen`
            // panel instead gets the whole frame (`size`).
            let modal_area = if fullscreen || is_anchored {
                size
            } else {
                chrome_area
            };
            self.render_floating_widget_panel(frame, modal_area, super::PanelSlot::Floating);
        }

        // Workspace-trust prompt — a blocking, top-most security modal. Drawn
        // dead last (after the dock and any floating panel) so it dims the
        // *entire* frame, centres in the full window (dock area included), and
        // renders on top of the dock rather than being overpainted by it.
        let trust_layout = if top_is_trust_modal {
            let draw_trust = !self.suppress_chrome_cells;
            if draw_trust {
                crate::view::dimming::apply_dimming(frame, size);
            }
            let selected = self
                .global_popups
                .top()
                .and_then(|p| match &p.content {
                    crate::view::popup::PopupContent::List { selected, .. } => Some(*selected),
                    _ => None,
                })
                .unwrap_or(1);
            let path = self.working_dir().display().to_string();
            let triggers = self.workspace_trust_markers.join(", ");
            let secondary_label = if self.workspace_trust_prompt_cancellable {
                rust_i18n::t!("trust.dialog.btn_cancel").into_owned()
            } else {
                let quit_hint = self.keybindings.read().ok().and_then(|kb| {
                    kb.get_keybinding_for_action(
                        &crate::input::keybindings::Action::Quit,
                        crate::input::keybindings::KeyContext::Normal,
                    )
                });
                match quit_hint {
                    Some(k) => rust_i18n::t!("trust.dialog.btn_quit_key", key = k).into_owned(),
                    None => rust_i18n::t!("trust.dialog.btn_quit").into_owned(),
                }
            };
            Some(
                crate::view::workspace_trust_dialog::render_workspace_trust_dialog(
                    frame,
                    crate::view::workspace_trust_dialog::TrustDialogParams {
                        area: size,
                        selected,
                        path: &path,
                        triggers: &triggers,
                        secondary_label: &secondary_label,
                        scroll: self.workspace_trust_scroll,
                        theme: theme_clone,
                        draw: draw_trust,
                    },
                ),
            )
        } else {
            None
        };
        self.active_chrome_mut().workspace_trust_dialog = trust_layout;
    }

    /// Split `main_content_area` into the editor content area and the
    /// file-explorer sidebar rect, updating the cached explorer rect for hit
    /// testing. Returns `(editor_content_area, explorer_rect)`, where
    /// `explorer_rect` is `None` when the explorer is hidden (the whole region
    /// is the editor's). The split is kept while a sync is in progress, to
    /// avoid a one-frame flicker. This decides layout only — painting the
    /// sidebar is [`render_file_explorer`], which draws into the returned rect.
    fn split_file_explorer_area(
        &mut self,
        main_content_area: ratatui::layout::Rect,
    ) -> (ratatui::layout::Rect, Option<ratatui::layout::Rect>) {
        let file_explorer_should_show = self.file_explorer_visible()
            && (self.file_explorer().is_some()
                || self.active_window().file_explorer_sync_in_progress);

        if !file_explorer_should_show {
            // No file explorer: use entire main content area for editor
            self.active_layout_mut().file_explorer_area = None;
            return (main_content_area, None);
        }

        // Split horizontally based on side placement
        tracing::trace!(
            "render: file explorer layout active (present={}, sync_in_progress={}, side={:?})",
            self.file_explorer().is_some(),
            self.active_window().file_explorer_sync_in_progress,
            self.active_window().file_explorer_side
        );
        let explorer_cols = self
            .active_window()
            .file_explorer_width
            .to_cols(main_content_area.width);

        let (explorer_area, editor_area) = match self.active_window().file_explorer_side {
            FileExplorerSide::Left => {
                let chunks = Layout::default()
                    .direction(Direction::Horizontal)
                    .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
                    .split(main_content_area);
                (chunks[0], chunks[1])
            }
            FileExplorerSide::Right => {
                let chunks = Layout::default()
                    .direction(Direction::Horizontal)
                    .constraints([Constraint::Min(0), Constraint::Length(explorer_cols)])
                    .split(main_content_area);
                (chunks[1], chunks[0])
            }
        };

        self.active_layout_mut().file_explorer_area = Some(explorer_area);
        (editor_area, Some(explorer_area))
    }

    /// Paint the file-explorer sidebar into `area`. Composable: it draws only
    /// into the given rect and makes no layout decisions. A no-op when the
    /// explorer isn't materialised (mid-sync the rect stays reserved but
    /// blank).
    /// Placeholder page for a dormant remote session's shell: the workspace
    /// cannot show (or edit) anything until its backend connects, so instead
    /// of a tab bar + empty scratch buffer the content area is a blank page
    /// with the session's backend identity and live connection state —
    /// Connecting… while the dive's connect is in flight, the failure reason
    /// once it failed. The dock stays the way to switch away or retry.
    fn render_dormant_shell_page(&mut self, frame: &mut Frame, area: ratatui::layout::Rect) {
        use ratatui::style::{Modifier, Style};
        if area.width == 0 || area.height == 0 {
            return;
        }
        let active_id = self.active_window;
        let window = self.windows.get(&active_id).expect("active window exists");
        let label = window.label.clone();
        let detail = window
            .authority_spec
            .remote_backend_info(false)
            .map(|r| {
                let glyph = if r.kind == "kubernetes" { "⎈" } else { "⇅" };
                format!("{glyph} {label} — {}", r.detail)
            })
            .unwrap_or_else(|| format!("⇅ {label}"));
        let connecting = self
            .remote_attach_inflight
            .contains(&(u64::MAX - active_id.0));
        let state_line = if connecting {
            "Connecting…".to_string()
        } else if let Some(reason) = &window.remote_reconnect_error {
            format!("Disconnected — {reason}")
        } else {
            "Not connected".to_string()
        };
        // Soft, state-appropriate hints: while connecting the workspace may
        // open at any moment; only a recorded failure suggests retrying.
        let (hint, retry_hint) = if connecting || window.remote_reconnect_error.is_none() {
            (
                "The workspace will open as soon as the connection is established.",
                "",
            )
        } else {
            (
                "The workspace could not be loaded without its connection.",
                "Select it again in the dock (or use the status-bar indicator) to reconnect.",
            )
        };

        let (bg, fg, dim) = {
            let theme = self.theme.read().unwrap();
            (theme.editor_bg, theme.editor_fg, theme.line_number_fg)
        };
        let buf = frame.buffer_mut();
        // Blank the whole content area — over the tab bar and scratch buffer
        // the split renderer just painted.
        for y in area.top()..area.bottom() {
            for x in area.left()..area.right() {
                if let Some(cell) = buf.cell_mut(ratatui::layout::Position::new(x, y)) {
                    cell.set_symbol(" ");
                    cell.set_style(Style::default().bg(bg));
                }
            }
        }
        // Centered message block.
        let lines: [(&str, Style); 5] = [
            (
                detail.as_str(),
                Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
            ),
            ("", Style::default().bg(bg)),
            (state_line.as_str(), Style::default().fg(fg).bg(bg)),
            ("", Style::default().bg(bg)),
            (hint, Style::default().fg(dim).bg(bg)),
        ];
        let block_height = lines.len() as u16 + 1;
        let top = area.top() + area.height.saturating_sub(block_height) / 2;
        let draw_centered =
            |buf: &mut ratatui::buffer::Buffer, y: u16, text: &str, style: Style| {
                if y >= area.bottom() || text.is_empty() {
                    return;
                }
                // Truncate to the area (char-boundary safe) with an ellipsis.
                let max = area.width.saturating_sub(2) as usize;
                let truncated: String = if text.chars().count() > max {
                    let mut t: String = text.chars().take(max.saturating_sub(1)).collect();
                    t.push('…');
                    t
                } else {
                    text.to_string()
                };
                let w = truncated.chars().count() as u16;
                let x = area.left() + area.width.saturating_sub(w) / 2;
                buf.set_string(x, y, &truncated, style);
            };
        for (i, (text, style)) in lines.iter().enumerate() {
            draw_centered(buf, top + i as u16, text, *style);
        }
        draw_centered(
            buf,
            top + lines.len() as u16,
            retry_hint,
            Style::default().fg(dim).bg(bg),
        );
    }

    fn render_file_explorer(&mut self, frame: &mut Frame, area: ratatui::layout::Rect) {
        // Get connection string before mutable borrow of file_explorer.
        let remote_connection = self.connection_display_string();

        // Render file explorer (only if we have it - during sync we just keep the area reserved).
        // Uses direct `self.windows.get_mut(...)` (not `file_explorer_mut()`) so the body
        // can keep reading other Editor fields (buffers, theme, keybindings, …) — Rust
        // splits the borrow on `self.windows` from the borrows on those other fields.
        let active_id = self.active_window;
        // Read window-state inputs before taking the &mut borrow on the
        // window for the explorer/buffer access below.
        // The explorer reads as focused only when it actually owns the
        // keyboard — not when a focused orchestrator dock has stolen it
        // out from under the (still-FileExplorer) window context. Without
        // this guard the explorer keeps its accent border while the dock
        // is driving, making it ambiguous which panel is focused.
        let is_focused = self.active_window().key_context == KeyContext::FileExplorer
            && !self.dock.as_ref().is_some_and(|d| d.focused);
        let key_context_clone = self.active_window().key_context.clone();
        let close_button_hovered = matches!(
            &self.active_window().mouse_state.hover_target,
            Some(HoverTarget::FileExplorerCloseButton)
        );
        let slot_resolver = self.file_explorer_slot_resolver();
        // Theme-key runs the explorer records as it paints; applied to the
        // chrome cell map after the window borrow is released.
        let mut fe_runs: Vec<crate::app::types::ThemeRun> = Vec::new();
        // Web renders the sidebar natively from `file_explorer_view`; skip
        // its cell drawing (layout/viewport still applied).
        let fe_draw = !self.suppress_chrome_cells;
        // Take one &mut on the active window; the explorer + buffers
        // come from disjoint sub-fields so they can coexist.
        let __win = self
            .windows
            .get_mut(&active_id)
            .expect("active window must exist");
        let __buffers_ref: &crate::app::window::WindowBuffers = &__win.buffers;
        if let Some(explorer) = __win.file_explorer.as_mut() {
            // Build set of files with unsaved changes
            let mut files_with_unsaved_changes = std::collections::HashSet::new();
            for (buffer_id, state) in __buffers_ref {
                if state.buffer.is_modified() {
                    if let Some(metadata) = __win.buffer_metadata.get(buffer_id) {
                        if let Some(file_path) = metadata.file_path() {
                            files_with_unsaved_changes.insert(file_path.clone());
                        }
                    }
                }
            }

            let keybindings = self.keybindings.read().unwrap();
            let empty: Vec<std::path::PathBuf> = Vec::new();
            let cut_paths = __win
                .file_explorer_clipboard
                .as_ref()
                .filter(|cb| cb.is_cut)
                .map(|cb| cb.paths.as_slice())
                .unwrap_or(empty.as_slice());
            let deco = ExplorerDecorations {
                slot_resolver,
                decorations: &__win.file_explorer_decoration_cache,
                slot_overrides: &__win.file_explorer_slot_override_cache,
            };
            FileExplorerRenderer::render(
                explorer,
                frame,
                area,
                deco,
                is_focused,
                &files_with_unsaved_changes,
                &keybindings,
                key_context_clone,
                &self.theme.read().unwrap(),
                close_button_hovered,
                remote_connection.as_deref(),
                cut_paths,
                &self.config.file_explorer,
                &mut crate::app::types::CellThemeRecorder::new(&mut fe_runs),
                fe_draw,
            );
        } else if fe_draw {
            // The tree isn't materialised yet but the column is reserved
            // (initial build or expand-to-path sync in progress). Paint the
            // panel's FINAL chrome — same title, borders, and close button as
            // the loaded panel — with a "Loading…" body, so the explorer
            // *visibly* appears the instant its window does and the tree
            // landing later changes nothing but the list content. The window
            // therefore never paints in two stages around the (always async,
            // local and remote alike) tree build: the layout and chrome of
            // the first frame are already final.
            let keybindings = self.keybindings.read().unwrap();
            FileExplorerRenderer::render_loading(
                frame,
                area,
                is_focused,
                &keybindings,
                key_context_clone,
                &self.theme.read().unwrap(),
                close_button_hovered,
                remote_connection.as_deref(),
            );
        }
        self.active_chrome_mut().apply_theme_runs(&fe_runs);
    }

    /// Render the status bar into `area`, unless it's toggled off or a
    /// suggestions / file-browser popup is occupying the bottom row. The
    /// status-bar-only inputs (LSP segment, messages, chord state, update
    /// banner, …) are computed here; `display_name`, `theme`, and
    /// `keybindings` are shared with other chrome and passed in.
    #[allow(clippy::too_many_arguments)]
    fn render_status_bar_row(
        &mut self,
        frame: &mut Frame,
        area: ratatui::layout::Rect,
        display_name: &str,
        theme: &crate::view::theme::Theme,
        keybindings: &crate::input::keybindings::KeybindingResolver,
        has_suggestions: bool,
        has_file_browser: bool,
    ) {
        let status_message = self.active_window().status_message.clone();
        let plugin_status_message = self.active_window().plugin_status_message.clone();
        // Compute a simple buffer-aware LSP indicator.
        // Compose the LSP status-bar segment for the active buffer. This
        // runs every render — the editor has no precomputed LSP-status
        // string cached anywhere else, so there is a single source of
        // truth for what the user sees.
        //
        // Priority order (first non-empty wins):
        //
        //   1. Active `$/progress` work for this language — e.g.
        //      "LSP (cpp): indexing (42%)". Conveys the transient
        //      startup/indexing phase.
        //   2. A running server — "LSP". Short because detail belongs
        //      in LSP-specific UI, not the compact status bar pill.
        //   3. Configured `auto_start=true` servers that haven't started
        //      (error / crashed / pending) — "LSP off".
        //   4. Configured `enabled && !auto_start` servers that the user
        //      has to opt into — "LSP: off (N)".
        //   5. Nothing.
        //
        // Rules 3 and 4 address heuristic eval H-1: without them, a
        // configured-but-dormant server is indistinguishable from "no
        // LSP at all."
        let current_language = self
            .buffers()
            .get(&self.active_buffer())
            .map(|s| s.language.clone())
            .unwrap_or_default();
        let buffer_lsp_disabled_reason = self
            .active_window()
            .buffer_metadata
            .get(&self.active_buffer())
            .filter(|m| !m.lsp_enabled)
            .and_then(|m| m.lsp_disabled_reason.as_deref());
        let (lsp_status, lsp_indicator_state) = compose_lsp_status(
            &current_language,
            buffer_lsp_disabled_reason,
            &self.active_window().lsp_progress,
            &self.active_window().lsp_server_statuses,
            &self.config.lsp,
            &self.active_window().user_dismissed_lsp_languages,
            self.config.lsp_enabled,
        );
        let chord_state_cloned = self.active_window().chord_state.clone(); // Clone the chord state

        // Get update availability info
        let update_available = self.latest_version().map(|v| v.to_string());
        let self_update_phase = self.self_update_phase();

        // Render status bar (hidden when toggled off, or when suggestions/file browser popup is shown)
        if self.active_window().status_bar_visible && !has_suggestions && !has_file_browser {
            // Get warning level for colored indicator (respects config setting)
            // LSP warning level is scoped to the current buffer's language
            let (warning_level, general_warning_count) =
                if self.config.warnings.show_status_indicator {
                    let lsp_level = {
                        use crate::services::async_bridge::LspServerStatus;
                        let mut level = WarningLevel::None;
                        for ((lang, _), status) in &self.active_window().lsp_server_statuses {
                            if lang == &current_language {
                                match status {
                                    LspServerStatus::Error => {
                                        level = WarningLevel::Error;
                                        break;
                                    }
                                    LspServerStatus::Starting | LspServerStatus::Initializing
                                        if level != WarningLevel::Error =>
                                    {
                                        level = WarningLevel::Warning;
                                    }
                                    _ => {}
                                }
                            }
                        }
                        level
                    };
                    (
                        lsp_level,
                        self.active_window().warning_domains.general.count,
                    )
                } else {
                    (WarningLevel::None, 0)
                };

            // Which clickable status-bar segment (if any) the mouse is over —
            // drives hover styling generically (one variant for the whole bar).
            let status_bar_hovered = match &self.active_window().mouse_state.hover_target {
                Some(HoverTarget::StatusBarClickable(id)) => Some(*id),
                _ => None,
            };

            let remote_connection = self.connection_display_string();
            // Active window's last failed-reconnect error (drives a core
            // FailedAttach indicator for a dormant remote workspace).
            let remote_reconnect_error = self.active_window().remote_reconnect_error.clone();
            // The active window is a remote session whose window-derived
            // connect (dive / retry; see `start_remote_reconnect`'s request-id
            // scheme) is still in flight — its shell shows `Connecting`.
            let remote_connecting = self
                .remote_attach_inflight
                .contains(&(u64::MAX - self.active_window_id().0))
                && self.active_window().authority_spec.is_remote();

            // Get session label for display (only in session mode). The display
            // name, not `session_name`: an unnamed working-directory daemon has
            // no daemon name but is still labelled with its directory.
            let session_name = self.session_display_name().map(|s| s.to_string());

            let active_split = self.effective_active_split();
            let active_buf = self.active_buffer();
            let default_cursors = crate::model::cursor::Cursors::new();
            let is_read_only = self
                .active_window()
                .buffer_metadata
                .get(&active_buf)
                .map(|m| m.read_only)
                .unwrap_or(false);
            let is_synthetic_placeholder = self
                .active_window()
                .buffer_metadata
                .get(&active_buf)
                .map(|m| m.synthetic_placeholder)
                .unwrap_or(false);
            // Compute plugin-provided status-bar values before taking the
            // mutable window borrow below.
            let dynamic_status_bar_elements = self.get_status_bar_element_values(active_buf);
            // Active session's trust level for the always-present `{trust}`
            // indicator — read here (Copy) before the mutable window borrow.
            let workspace_trust_level = self.authority().workspace_trust.level();
            // Restart affordance for a terminal buffer whose process quit.
            // `exited_terminal` is `Some` only in exactly that state, so the
            // indicator can't offer to restart a live agent.
            let terminal_restart = self.active_window().exited_terminal(active_buf).map(|e| {
                crate::view::ui::status_bar::TerminalRestartState {
                    program: e.program_name().map(str::to_string),
                    exit_code: e.exit_code,
                    resumes_agent: e.resumes_agent() && self.config.terminal.resume_agents,
                }
            });
            // Single window borrow, split into buffers + cursors so the
            // status-bar context can hold both.
            let __active_id = self.active_window;
            // Theme-key runs the status bar records as it paints; applied to
            // the chrome's cell map after the window borrow is released.
            let mut status_bar_runs: Vec<crate::app::types::ThemeRun> = Vec::new();
            // Web renders the status bar natively from `status_view`; skip painting
            // it (the semantic segments + indicator rects are still captured).
            let sb_draw = !self.suppress_chrome_cells;
            let __win = self
                .windows
                .get_mut(&__active_id)
                .expect("active window must exist");
            let status_bar_layout = __win
                .buffers
                .with_buffer_and_view_states(active_buf, |state, vs_map| {
                    let cursors = vs_map
                        .get(&active_split)
                        .map(|v| &v.cursors)
                        .unwrap_or(&default_cursors);
                    let mut status_ctx = crate::view::ui::status_bar::StatusBarContext {
                        state,
                        cursors,
                        status_message: &status_message,
                        plugin_status_message: &plugin_status_message,
                        lsp_status: &lsp_status,
                        lsp_indicator_state,
                        theme,
                        display_name,
                        keybindings,
                        chord_state: &chord_state_cloned,
                        update_available: update_available.as_deref(),
                        update_phase: self_update_phase,
                        warning_level,
                        general_warning_count,
                        hovered: status_bar_hovered,
                        remote_connection: remote_connection.as_deref(),
                        session_name: session_name.as_deref(),
                        read_only: is_read_only,
                        remote_state_override: self.remote_indicator_override.as_ref(),
                        remote_reconnect_error: remote_reconnect_error.as_deref(),
                        remote_connecting,
                        is_synthetic_placeholder,
                        // Filled in by `render_status` from the user's
                        // status_bar config; the value here is just a
                        // safe default for the rare path that builds the
                        // ctx but doesn't run `render_status`.
                        remote_indicator_on_bar: false,
                        dynamic_status_bar_elements: dynamic_status_bar_elements.clone(),
                        workspace_trust_level,
                        terminal_restart: terminal_restart.clone(),
                    };
                    let mut sb_rec =
                        crate::app::types::CellThemeRecorder::new(&mut status_bar_runs);
                    StatusBarRenderer::render_status_bar(
                        frame,
                        area,
                        &mut status_ctx,
                        &self.config.editor.status_bar,
                        Some(&mut sb_rec),
                        sb_draw,
                    )
                })
                .expect("active buffer must be present");
            self.active_chrome_mut().apply_theme_runs(&status_bar_runs);

            // Store status bar layout for click detection
            let status_bar = &mut self.active_chrome_mut().status_bar;
            status_bar.area = Some((area.y, area.x, area.width));
            status_bar.clickable = status_bar_layout.clickable;
            status_bar.plugin_token_areas = status_bar_layout.plugin_token_areas;
            status_bar.segments = status_bar_layout.segments;
        } else {
            // No bar this frame — the user hid it, or a suggestions / file-
            // browser popup took the row. Drop last frame's capture instead of
            // leaving it to go stale: `status_view` would keep projecting a
            // status bar the web then draws under its prompt row (the TUI's
            // ghost-text bug), and the click/hover hit-tests would keep
            // resolving segments that are no longer on screen.
            self.active_chrome_mut().status_bar = Default::default();
        }
    }

    /// Render the modal overlays that dim everything behind them: settings,
    /// calibration wizard, keybinding editor, and event-debug dialog. Each is
    /// drawn only for the TUI (`!suppress_chrome_cells`); the web projects
    /// them natively.
    ///
    /// `area` is the whole frame — these are full-screen modals, so the dim
    /// pass covers the dock column too and each dialog centres in the full
    /// window. They are called from `render_panels_and_modals` (after the
    /// dock paints) so the dock cannot overpaint them.
    fn render_modal_overlays(&mut self, frame: &mut Frame, area: ratatui::layout::Rect) {
        // Check visibility first to avoid borrow conflict with dimming
        // The web renders Settings natively from `settings_view`; paint cells
        // only for the TUI.
        let draw_settings = !self.suppress_chrome_cells;
        let settings_visible = draw_settings
            && self
                .settings_state
                .as_ref()
                .map(|s| s.visible)
                .unwrap_or(false);
        if settings_visible {
            // Dim everything behind the settings modal — the editor chrome
            // *and* the dock. The dock is input-inaccessible while the modal
            // is up (`dispatch_modal_mouse` routes every click to settings),
            // so leaving it at full brightness read as if it were still live
            // beside a dialog that had already swallowed its input.
            crate::view::dimming::apply_dimming(frame, area);
        }
        if let Some(ref mut settings_state) = self.settings_state {
            if !draw_settings {
                // keyboard-driven native render; skip the cell layout pass but
                // still sync per-control focus states — they're pure state the
                // scene projection reads (map/list entry highlight), not
                // something tied to cell geometry.
                if settings_state.visible {
                    settings_state.update_focus_states();
                }
            } else if settings_state.visible {
                settings_state.update_focus_states();
                let settings_layout = crate::view::settings::render_settings(
                    frame,
                    area,
                    settings_state,
                    &self.theme.read().unwrap(),
                );
                self.active_chrome_mut().settings_layout = Some(settings_layout);
            }
        }

        // Render calibration wizard if active. (Deprecated; the web has no native
        // projection for it, so suppress its cells there rather than bleed.)
        if !self.suppress_chrome_cells {
            if let Some(ref wizard) = self.calibration_wizard {
                // Dim the editor content behind the wizard modal
                crate::view::dimming::apply_dimming(frame, area);
                crate::view::calibration_wizard::render_calibration_wizard(
                    frame,
                    area,
                    wizard,
                    &self.theme.read().unwrap(),
                );
            }
        }

        // Event-debug: the web renders it natively from `aux_modals_view`; paint
        // cells only for the TUI.
        let draw_aux = !self.suppress_chrome_cells;

        // Keybinding editor: web renders it natively from `keybinding_editor_view`;
        // paint cells only for the TUI.
        if draw_aux {
            if let Some(ref mut kb_editor) = self.keybinding_editor {
                crate::view::dimming::apply_dimming(frame, area);
                crate::view::keybinding_editor::render_keybinding_editor(
                    frame,
                    area,
                    kb_editor,
                    &self.theme.read().unwrap(),
                );
            }
        }

        // Render event debug dialog if active
        if draw_aux {
            if let Some(ref debug) = self.active_window().event_debug {
                // Dim the editor content behind the dialog modal
                crate::view::dimming::apply_dimming(frame, area);
                crate::view::event_debug::render_event_debug(
                    frame,
                    area,
                    debug,
                    &self.theme.read().unwrap(),
                );
            }
        }
    }

    /// Render the menu bar into `menu_bar_area`, or clear the cached layout
    /// when the bar is hidden. Drawn late in `render` so its dropdowns sit
    /// above all other content.
    fn render_menu_bar(&mut self, frame: &mut Frame, menu_bar_area: ratatui::layout::Rect) {
        if self.active_window().menu_bar_visible {
            // Pre-expand DynamicSubmenu items once per registry; without this
            // MenuRenderer::render rescans + reparses every theme JSON file
            // on every frame.
            self.expanded_menus_cache.update(
                &self.theme_registry,
                &self.menus,
                &self.menu_state.themes_dir,
            );
            let hover_target = self.active_window().mouse_state.hover_target.clone();
            let menu_bar_mnemonics = self.config.editor.menu_bar_mnemonics;
            let draw_chrome = !self.suppress_chrome_cells;
            // The single content source shared with the web `menu_view()`
            // projection (uses the cache populated just above).
            let all_menus = self.all_menus_expanded();
            let keybindings = self.keybindings.read().unwrap();
            let mut menu_runs: Vec<crate::app::types::ThemeRun> = Vec::new();
            let new_menu_layout = crate::view::ui::MenuRenderer::render(
                frame,
                menu_bar_area,
                &all_menus,
                &self.menu_state,
                &keybindings,
                &self.theme.read().unwrap(),
                hover_target.as_ref(),
                menu_bar_mnemonics,
                Some(&mut crate::app::types::CellThemeRecorder::new(
                    &mut menu_runs,
                )),
                draw_chrome,
            );
            drop(keybindings);
            self.active_chrome_mut().menu_layout = Some(new_menu_layout);
            self.active_chrome_mut().apply_theme_runs(&menu_runs);
        } else {
            self.active_chrome_mut().menu_layout = None;
        }
    }

    /// Render the tab, file-explorer, and new-tab context menus. TUI-only
    /// cell drawing; the web projects these natively from `context_menu_view`.
    fn render_context_menus(&mut self, frame: &mut Frame) {
        if self.suppress_chrome_cells {
            return;
        }
        // Only one native context menu is ever open at a time; the shared
        // core + item labels drive a single generic renderer.
        let Some((_, core)) = self.active_window().open_context_menu() else {
            return;
        };
        let core = core.clone();
        let Some(items) = self.active_window().context_menu_labels() else {
            return;
        };
        self.render_context_menu(frame, &core, &items);
    }

    /// Drain plugin commands enqueued before this frame's layout pass.
    ///
    /// Must run before `compute_dock_split` because commands such as
    /// `UnmountFloatingWidget` affect the dock state that layout reads.
    /// Draining any later would compute the dock area from stale state and
    /// leave the freed columns blank until the next input event — which is why
    /// this is the render path's only dispatch point.
    fn drain_pre_layout_plugin_commands(&mut self) {
        #[cfg(feature = "plugins")]
        {
            // The one and only dispatch point on the render path, and it runs
            // before anything is laid out or painted — semantically between
            // frames, so no command can tear this frame or invalidate its
            // layout (the old mid-render drain needed a layout recompute and
            // a hold-back list for window switches; this needs neither).
            // Routed through the same budgeted, backlogged, measured drain as
            // the tick so global FIFO order is preserved.
            let _ = self.process_plugin_commands();
        }
    }

    /// Ensure the active split's cursor is in view, then synchronise scroll-sync groups.
    ///
    /// Order matters: `sync_scroll_groups` reads the `viewport.top_byte()` that
    /// `pre_sync_ensure_visible` just updated.  Doing it after the render would
    /// produce a one-frame lag on cursor moves that trigger a scroll-sync anchor
    /// change (e.g. `G` in a side-by-side diff).
    fn pre_sync_and_scroll_sync(&mut self) {
        let active_split = self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.splits())
            .map(|(mgr, _)| mgr)
            .expect("active window must have a populated split layout")
            .active_split();
        {
            let _span = tracing::info_span!("pre_sync_ensure_visible").entered();
            self.active_window_mut()
                .pre_sync_ensure_visible(active_split);
        }
        {
            let _span = tracing::info_span!("sync_scroll_groups").entered();
            self.active_window_mut().sync_scroll_groups();
        }
    }

    /// Compute the visible byte range for each split and issue debounced LSP
    /// requests for semantic tokens and folding ranges.
    fn request_semantic_ranges_for_visible_splits(&mut self) {
        let mut semantic_ranges: std::collections::HashMap<BufferId, (usize, usize)> =
            std::collections::HashMap::new();
        {
            let _span = tracing::info_span!("compute_semantic_ranges").entered();
            for (split_id, view_state) in self
                .windows
                .get(&self.active_window)
                .and_then(|w| w.buffers.splits())
                .map(|(_, vs)| vs)
                .expect("active window must have a populated split layout")
            {
                if let Some(buffer_id) = self
                    .windows
                    .get(&self.active_window)
                    .and_then(|w| w.buffers.splits())
                    .map(|(mgr, _)| mgr)
                    .expect("active window must have a populated split layout")
                    .get_buffer_id((*split_id).into())
                {
                    if let Some(state) = self
                        .windows
                        .get(&self.active_window)
                        .map(|w| &w.buffers)
                        .expect("active window present")
                        .get(&buffer_id)
                    {
                        let start_line =
                            state.buffer.get_line_number(view_state.viewport.top_byte());
                        let visible_lines =
                            view_state.viewport.visible_line_count().saturating_sub(1);
                        let end_line = start_line.saturating_add(visible_lines);
                        semantic_ranges
                            .entry(buffer_id)
                            .and_modify(|(min_start, max_end)| {
                                *min_start = (*min_start).min(start_line);
                                *max_end = (*max_end).max(end_line);
                            })
                            .or_insert((start_line, end_line));
                    }
                }
            }
        }
        for (buffer_id, (start_line, end_line)) in semantic_ranges {
            self.maybe_request_semantic_tokens_range(buffer_id, start_line, end_line);
            self.maybe_request_semantic_tokens_full_debounced(buffer_id);
            self.maybe_request_folding_ranges_debounced(buffer_id);
        }
    }

    /// Pre-load viewport data for each visible buffer.
    ///
    /// Large files use lazy loading: data outside the viewport isn't in memory.
    /// This pass materialises the bytes each split needs before the renderer
    /// touches them, so the render sees a fully-populated buffer.
    fn prepare_visible_buffers_for_render(&mut self) {
        let _span = tracing::info_span!("prepare_for_render").entered();
        // Pre-collect targets so we can take a mut borrow on buffers below
        // without holding the immutable read borrow on self.windows.
        let active_id = self.active_window;
        let prep_targets: Vec<(BufferId, usize, u16)> = {
            let win = self
                .windows
                .get(&active_id)
                .expect("active window must exist");
            let (mgr, vs_map) = win
                .buffers
                .splits()
                .expect("active window must have a populated split layout");
            vs_map
                .iter()
                .filter_map(|(split_id, vs)| {
                    mgr.get_buffer_id((*split_id).into())
                        .map(|bid| (bid, vs.viewport.top_byte(), vs.viewport.height))
                })
                .collect()
        };
        let win_buffers = &mut self
            .windows
            .get_mut(&active_id)
            .expect("active window must exist")
            .buffers;
        for (buffer_id, top_byte, height) in prep_targets {
            if let Some(state) = win_buffers.get_mut(&buffer_id) {
                if let Err(e) = state.prepare_for_render(top_byte, height) {
                    tracing::error!("Failed to prepare buffer for render: {}", e);
                }
            }
        }
    }

    /// Compare the hardware cursor's screen position to the previous frame's
    /// and, if it moved by more than the "jump" threshold, start a
    /// `CursorJump` animation from the old to the new on-screen position.
    /// Successive jumps cancel the prior animation so trail effects don't
    /// pile up.
    ///
    /// Cross-split and cross-buffer transitions (focus change, tab switch)
    /// are also animated — the trail crosses pane separators on its way
    /// from one buffer's cursor cell to another's.
    ///
    /// The threshold is intentionally generous: arrow-key/typing moves
    /// (small `dx`/`dy`) must NOT trigger the animation, but search jumps,
    /// goto-line/definition, and pane switches (which always cross several
    /// rows or many columns) must.
    fn maybe_start_cursor_jump_animation(
        &mut self,
        current_pos: Option<(u16, u16)>,
        active_split: crate::model::event::LeafId,
    ) {
        // Honour the global animations toggle. Tests default to
        // `animations = false` so single-tick `render()` calls observe the
        // settled buffer instead of a mid-flight trail; users can also
        // disable animations entirely from config. The dedicated
        // `cursor_jump_animation` toggle suppresses just the cursor-jump
        // trail while leaving ambient animations (tab slides, dashboard,
        // plugin effects) running.
        if !self.config.editor.animations || !self.config.editor.cursor_jump_animation {
            self.previous_cursor_screen_pos = current_pos.map(|p| (p, active_split));
            return;
        }

        let Some(current) = current_pos else {
            // Cursor is hidden this frame (e.g. prompt has focus). Reset the
            // tracker so the re-emerging cursor doesn't animate from a stale
            // spot when focus returns to a buffer.
            self.previous_cursor_screen_pos = None;
            return;
        };

        let prev_entry = self.previous_cursor_screen_pos;
        // Update tracking unconditionally for the next frame.
        self.previous_cursor_screen_pos = Some((current, active_split));

        let Some((prev, prev_split)) = prev_entry else {
            return;
        };
        if prev == current && prev_split == active_split {
            return;
        }

        let dx = (current.0 as i32 - prev.0 as i32).abs();
        let dy = (current.1 as i32 - prev.1 as i32).abs();
        // Animate when the cursor crossed split panes, or when it made a
        // non-incremental move within the same pane: more than two rows
        // vertically, or — for moves that stay within ±2 rows — at
        // least 80 columns horizontally. The horizontal threshold is
        // generous because typing, arrow keys, word-jump, and Home/End
        // on long source lines can all exceed a smaller bound without
        // being a genuine "jump".
        let crossed_panes = prev_split != active_split;
        let row_jump = dy > 2;
        let col_jump = dx >= 80;
        if !crossed_panes && !row_jump && !col_jump {
            return;
        }

        // Cancel any prior cursor-jump animation so trails don't stack.
        if let Some(prev_anim) = self.cursor_jump_animation.take() {
            self.active_window_mut().animations.cancel(prev_anim);
        }

        let cursor_color = self.theme.read().unwrap().cursor;
        let bg_color = self.theme.read().unwrap().editor_bg;
        let id = self.active_window_mut().animations.start(
            // The bounding box is for runner bookkeeping only — CursorJump
            // paints at absolute screen coords and ignores `area`.
            ratatui::layout::Rect {
                x: prev.0.min(current.0),
                y: prev.1.min(current.1),
                width: dx as u16 + 1,
                height: dy as u16 + 1,
            },
            crate::view::animation::AnimationKind::CursorJump {
                from: prev,
                to: current,
                duration: std::time::Duration::from_millis(140),
                cursor_color,
                bg_color,
            },
        );
        self.cursor_jump_animation = Some(id);
    }

    /// Returns true if `(x, y)` falls inside any popup-style overlay that
    /// was rendered this frame. Used to decide whether the hardware cursor
    /// should be shown or hidden so it does not bleed through a popup.
    fn cursor_obscured_by_overlay(&self, x: u16, y: u16) -> bool {
        let inside = |rect: ratatui::layout::Rect| -> bool {
            x >= rect.x
                && x < rect.x.saturating_add(rect.width)
                && y >= rect.y
                && y < rect.y.saturating_add(rect.height)
        };

        if self
            .active_chrome()
            .popup_areas
            .iter()
            .any(|entry| inside(entry.1))
        {
            return true;
        }
        if self
            .active_chrome()
            .global_popup_areas
            .iter()
            .any(|entry| inside(entry.1))
        {
            return true;
        }
        if let Some((rect, _, _, _)) = self.active_chrome().suggestions_area {
            if inside(rect) {
                return true;
            }
        }
        if let Some(ref fb) = self.active_window().file_browser_layout {
            if inside(fb.popup_area) {
                return true;
            }
        }
        false
    }

    /// Render the Quick Open hints line showing available mode prefixes
    fn render_quick_open_hints(
        frame: &mut Frame,
        area: ratatui::layout::Rect,
        theme: &crate::view::theme::Theme,
    ) {
        use ratatui::style::{Modifier, Style};
        use ratatui::text::{Line, Span};
        use ratatui::widgets::Paragraph;
        use rust_i18n::t;

        let hints_style = Style::default()
            .fg(theme.line_number_fg)
            .bg(theme.suggestion_selected_bg)
            .add_modifier(Modifier::DIM);
        let hints_text = t!("quick_open.mode_hints");
        // Left-align with small margin
        let left_margin = 2;
        let hints_width = crate::primitives::display_width::str_width(&hints_text);
        let mut spans = Vec::new();
        spans.push(Span::styled(" ".repeat(left_margin), hints_style));
        spans.push(Span::styled(hints_text.to_string(), hints_style));
        let remaining = (area.width as usize).saturating_sub(left_margin + hints_width);
        spans.push(Span::styled(" ".repeat(remaining), hints_style));

        let paragraph = Paragraph::new(Line::from(spans));
        frame.render_widget(paragraph, area);
    }

    /// Apply dimming effect to UI elements outside the focused terminal area
    /// This visually indicates that keyboard capture mode is active
    fn apply_keyboard_capture_dimming(
        &self,
        frame: &mut Frame,
        terminal_area: ratatui::layout::Rect,
    ) {
        let size = frame.area();
        crate::view::dimming::apply_dimming_excluding(frame, size, Some(terminal_area));
    }

    /// Render file browser or suggestions popup as overlay above the prompt line.
    /// Called after status bar + prompt so the popup draws on top of both.
    fn render_prompt_popups(
        &mut self,
        frame: &mut Frame,
        prompt_area: ratatui::layout::Rect,
        chrome: ratatui::layout::Rect,
    ) {
        let width = chrome.width;
        let Some(prompt) = &self.active_window_mut().prompt else {
            return;
        };

        // Overlay prompts (Live Grep, issue #1796) get a dedicated
        // centred floating frame instead of the bottom-anchored popup.
        // Centre it in the chrome area (right of a left dock) so it never
        // overlaps the dock column.
        if prompt.overlay {
            self.render_overlay_prompt(frame, chrome);
            return;
        }

        if matches!(
            prompt.prompt_type,
            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
        ) {
            let hover_target = self.active_window().mouse_state.hover_target.clone();
            let theme = self.theme.read().unwrap().clone();
            let keybindings = self.keybindings.read().unwrap();
            let kb_clone = keybindings.clone();
            drop(keybindings);
            let max_height = prompt_area.y.saturating_sub(1).min(20);
            let popup_area = ratatui::layout::Rect {
                // Anchor to the prompt line's x (right of a left dock,
                // if any) so the picker never overlaps the dock column.
                x: prompt_area.x,
                y: prompt_area.y.saturating_sub(max_height),
                width,
                height: max_height,
            };
            // Web renders the browser natively from `file_browser_view`; skip
            // its cell drawing (layout, spans and the list viewport are still
            // computed, and the projection reads them).
            let fb_draw = !self.suppress_chrome_cells;
            let __win = self.active_window_mut();
            let Some(file_open_state) = &mut __win.file_open_state else {
                return;
            };
            __win.file_browser_layout = crate::view::ui::FileBrowserRenderer::render(
                frame,
                popup_area,
                file_open_state,
                &theme,
                &hover_target,
                Some(&kb_clone),
                fb_draw,
            );
            return;
        }

        if prompt.suggestions.is_empty() {
            return;
        }

        let suggestion_count = prompt.suggestions.len().min(10);
        let is_quick_open = prompt.prompt_type == crate::view::prompt::PromptType::QuickOpen;
        let hints_height: u16 = if is_quick_open { 1 } else { 0 };
        let height = suggestion_count as u16 + 2 + hints_height;

        let suggestions_area = ratatui::layout::Rect {
            x: prompt_area.x,
            y: prompt_area.y.saturating_sub(height),
            width,
            height: height - hints_height,
        };

        // Blank the buffer cells under the suggestions box only when the
        // pipeline is actually drawing chrome into cells (the TUI). In web mode
        // (`suppress_chrome_cells`) the palette is drawn as native DOM on top of
        // the cell buffer, so clearing here would punch a visible hole in the
        // buffer wherever the native palette is NOT positioned over these rows
        // (e.g. a centered-modal layout). Mirrors the gating every other chrome
        // `Clear` already uses (overlay prompt, preview pane): compute geometry,
        // don't mutate the cell buffer for chrome the frontend renders itself.
        if !self.suppress_chrome_cells {
            frame.render_widget(ratatui::widgets::Clear, suggestions_area);
        }

        // Adjust the prompt's scroll position to keep the selected item
        // visible, scrolling the minimum amount required.
        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
            prompt.ensure_selected_visible();
        }
        let Some(prompt) = &self.active_window().prompt else {
            return;
        };

        let new_suggestions_area = SuggestionsRenderer::render_with_hover(
            frame,
            suggestions_area,
            prompt,
            &self.theme.read().unwrap(),
            self.active_window().mouse_state.hover_target.as_ref(),
            true,
            !self.suppress_chrome_cells,
        );
        let chrome = self.active_chrome_mut();
        chrome.suggestions_area = new_suggestions_area;
        if chrome.suggestions_area.is_some() {
            chrome.suggestions_outer_area = Some(suggestions_area);
        }

        // The quick-open hints row is chrome drawn into cells; the web renders
        // no hints, so in `suppress_chrome_cells` mode we skip it entirely
        // rather than stamp hint glyphs into the buffer cells the frontend
        // slices (which would show through a centered-modal palette).
        if is_quick_open && !self.suppress_chrome_cells {
            let hints_area = ratatui::layout::Rect {
                // Align with the prompt / suggestions box, which sit in the
                // chrome area to the right of a left dock (`prompt_area.x`).
                // Hardcoding `x: 0` here drew the hints starting at the very
                // left edge — under the dock column — so the bar was
                // partially obscured by the dock and visibly misaligned with
                // the suggestions box stacked directly above it.
                x: prompt_area.x,
                y: prompt_area.y.saturating_sub(hints_height),
                width,
                height: hints_height,
            };
            frame.render_widget(ratatui::widgets::Clear, hints_area);
            Self::render_quick_open_hints(frame, hints_area, &self.theme.read().unwrap());
        }
    }

    /// Resolve the overlay's currently-selected match into a real
    /// `Buffer` parked in a phantom `LeafId`, so the preview pane can
    /// reuse the regular per-leaf renderer (with syntax highlighting,
    /// gutter, scrollbars, folding). No-op when the prompt has no
    /// selection or its label is not a `path:line[:col]` triple.
    /// Render the entire stashed split tree of `self.preview_window_id`
    /// into `inner` — Primitive #1 of
    /// `docs/internal/orchestrator-sessions-design.md`'s "Rich
    /// Control Room rendering". Reuses the editor's existing
    /// `render_content` path against the previewed session's
    /// stashed `(SplitManager, view_states)` so syntax
    /// highlighting, terminal grids, decorations, and folding
    /// all surface natively in the preview pane.
    ///
    /// The previewed session's splits stash is `take`n out for
    /// the duration of the call (so we can pass `&mut` through
    /// the renderer without re-entering `self.windows`) and put
    /// back after. `pending_hardware_cursor` and
    /// `cell_theme_map` use scratch locals so the active editor
    /// area's hit-testing isn't clobbered by the preview pass.
    fn render_session_preview_into_rect(
        &mut self,
        frame: &mut ratatui::Frame,
        inner: ratatui::layout::Rect,
        theme: &crate::view::theme::Theme,
    ) {
        let Some(sid) = self.preview_window_id else {
            return;
        };

        // Lazy materialization: a previewed session whose workspace
        // hasn't been restored yet gets restored on its first preview
        // frame, so the embed paints real content. No-op once
        // materialized (cleared from `materialize_pending`).
        self.materialize_window(sid);

        // Terminal grid → buffer text "sync" was previously a
        // multi-step append/reload/truncate dance that mutated the
        // backing file on every preview-render frame just to make
        // the live screen visible inside the embed. That worked
        // around `render_terminal_splits` being hard-coded to the
        // active window's `terminal_buffers` map — during preview
        // the active window is the *caller's* session, so the
        // overlay couldn't find the previewed terminal.
        //
        // `render_terminal_splits` is now an `impl Window` method,
        // so the preview path can ask the previewed window
        // directly. The overlay paints the live PTY grid (with
        // colors, attributes, no cursor) on top of `SplitRenderer`'s
        // text rendering for every terminal buffer in the embed —
        // no file mutation, no reload, no truncate. The buffer's
        // backing file stays untouched between frames.

        // Pull the previewed window's split stash and sub-fields
        // out under one `&mut Window` borrow. Multiple disjoint
        // sub-borrows (`buffers`, `event_logs`, `splits`) coexist
        // on the same `Window`, so the renderer call can take all
        // three by `&mut` while the rest of `&mut self` stays
        // available for `composite_buffers` / `config` / etc.
        //
        // Step 0h: previously this used `splits.take()` + restore
        // because the inline-borrow patterns elsewhere couldn't
        // co-exist with a held `&mut sid.splits`. Now that all
        // per-window state lives on `Window`, we destructure
        // `splits.as_mut()` directly — no transient swap, no
        // side-effect plumbing — matching design Primitive #1.
        // Bail if the session has no stash yet (never been
        // activated and never had a terminal / file routed in via
        // createTerminal({windowId})), or has been closed under us
        // — e.g. an Orchestrator Archive / Delete completes between
        // the floating panel's spec being rebuilt and the next
        // render, so the embed's `windowId` momentarily points to
        // a window the host already removed. Early-return rather
        // than panic; the next plugin refresh re-emits the spec
        // without the dead embed.
        let preview_draw_tab_bar = !self.suppress_chrome_cells;
        // Same immutable render settings as the live editor, but with
        // scrollbars and tildes suppressed — they're noisy in a small
        // preview rect where the active session's chrome is authoritative.
        // Built before the `&mut self.windows` borrow (it only borrows
        // `self.config`).
        let preview_cfg = crate::view::ui::EditorRenderConfig {
            show_vertical_scrollbar: false,
            show_horizontal_scrollbar: false,
            show_tilde: false,
            ..crate::view::ui::EditorRenderConfig::new(
                &self.config.editor,
                self.background_fade,
                self.software_cursor_only,
            )
        };
        // Group the appearance inputs for the preview pass. `theme` is the
        // caller-supplied borrow; built before the `&mut self.windows` borrow.
        let preview_style = crate::view::ui::RenderStyle {
            theme,
            ansi_background: self.ansi_background.as_ref(),
            cfg: preview_cfg,
        };
        let Some(__win_for_preview) = self.windows.get_mut(&sid) else {
            return;
        };
        // Terminal splits shown in read-only scrollback in the previewed
        // window (computed before the mutable field borrows below). Mirrors the
        // active-window path so the preview's scrollbar suppression matches.
        let __preview_scrollback_splits: std::collections::HashSet<crate::model::event::LeafId> =
            __win_for_preview
                .buffers
                .splits()
                .map(|(_, vs_map)| {
                    vs_map
                        .iter()
                        .filter(|(leaf, svs)| {
                            __win_for_preview.split_terminal_scrollback(**leaf, svs.active_buffer)
                        })
                        .map(|(leaf, _)| *leaf)
                        .collect()
                })
                .unwrap_or_default();
        let __preview_metadata = &__win_for_preview.buffer_metadata;
        let __preview_buffer_id = __win_for_preview.preview.map(|(_, b)| b);
        let __preview_event_logs = &mut __win_for_preview.event_logs;
        let __preview_composite_buffers = &mut __win_for_preview.composite_buffers;
        let __preview_composite_view_states = &mut __win_for_preview.composite_view_states;
        // Issue #2035: pass the previewed window's actual
        // `grouped_subtrees` map. The previous code allocated an
        // empty HashMap here, which made the split renderer unable
        // to resolve any `active_group_tab` to its panel layout —
        // so a session whose active tab was a buffer group (e.g.
        // git_log's log/detail panels) silently fell through to
        // rendering the split's underlying pre-group buffer.
        let __preview_grouped_subtrees = &__win_for_preview.grouped_subtrees;
        let preview_tab_bar_visible = __win_for_preview.tab_bar_visible;

        // Per-call scratch — keeps the preview pass from
        // clobbering the active editor area's hit-testing /
        // hardware-cursor placement.
        let mut scratch_cell_theme_map: Vec<crate::app::types::CellThemeInfo> = Vec::new();
        let mut scratch_pending_cursor: Option<(u16, u16)> = None;
        let lsp_waiting = false; // preview never shows LSP-waiting chrome

        let mut preview_split_areas: Vec<(
            crate::model::event::LeafId,
            fresh_core::BufferId,
            ratatui::layout::Rect,
            ratatui::layout::Rect,
            usize,
            usize,
        )> = Vec::new();
        __win_for_preview
            .buffers
            .with_all_mut(|preview_buffers, mgr, view_states| {
                let result = crate::view::ui::SplitRenderer::render_content(
                    frame.buffer_mut(),
                    inner,
                    &*mgr,
                    preview_buffers,
                    __preview_metadata,
                    __preview_buffer_id,
                    __preview_event_logs,
                    __preview_composite_buffers,
                    __preview_composite_view_states,
                    preview_style,
                    lsp_waiting,
                    Some(view_states),
                    __preview_grouped_subtrees,
                    true, // hide_cursor — the active session owns the hardware caret
                    None, // no tab-hover routing in the preview
                    None,
                    None,
                    false, // not maximized
                    preview_tab_bar_visible,
                    self.session_mode || !self.software_cursor_only,
                    &__preview_scrollback_splits,
                    &mut scratch_cell_theme_map,
                    inner.width,
                    &mut scratch_pending_cursor,
                    preview_draw_tab_bar,
                );
                preview_split_areas = result.0;
            });

        // Resize the previewed window's terminal PTYs to fit the
        // preview embed before painting their grids. Without this,
        // the PTY child (e.g. `top`, `htop`, `vim`, claude) keeps
        // drawing at the dimensions it had when last active — often
        // the full terminal height — so the preview embed only
        // shows the top slice of a much taller frame. Resizing
        // SIGWINCHes the PTY, which redraws at the new size, and
        // the next render frame paints the correctly-sized grid.
        // When the user dives into the session,
        // `Window::resize_visible_terminals` will resize back up to
        // the dive view's split rect.
        if let Some(win) = self.windows.get_mut(&sid) {
            for (_split_id, buffer_id, content_rect, _scrollbar_rect, _, _) in &preview_split_areas
            {
                if win.terminal_buffers.contains_key(buffer_id)
                    && content_rect.width > 0
                    && content_rect.height > 0
                {
                    win.resize_terminal(*buffer_id, content_rect.width, content_rect.height);
                }
            }
        }

        // Overlay live PTY grids for terminal buffers in the
        // previewed window's splits — paints colors, attributes,
        // and the visible screen on top of `SplitRenderer`'s text
        // rendering. `cursor_visible_if_active = false` keeps the
        // preview read-only: no blinking cursor over a session
        // the user isn't currently driving.
        if let Some(win) = self.windows.get(&sid) {
            win.render_terminal_splits(frame, &preview_split_areas, false);
        }
    }

    fn prepare_overlay_preview(&mut self) {
        use crate::input::quick_open::parse_path_line_col;

        let parsed = {
            self.active_window()
                .prompt
                .as_ref()
                .and_then(|prompt| {
                    let idx = prompt.selected_suggestion?;
                    prompt.suggestions.get(idx)
                })
                .map(|s| {
                    // `value` is the authoritative `path:line:col` for the
                    // result. We must not rely on parsing the user-facing
                    // label (`text`), which may carry source badges (e.g.
                    // "[term]") that make it unparseable as a path. Only fall
                    // back to the label when `value` is absent/unparseable.
                    if let Some(v) = s.value.as_deref() {
                        let from_value = parse_path_line_col(v);
                        if !from_value.0.is_empty() && from_value.1.is_some() {
                            return from_value;
                        }
                    }
                    parse_path_line_col(&s.text)
                })
        };
        // No selectable result (empty list, no selection, or an
        // unparseable entry): blank the preview so the previous match's
        // content doesn't linger after the result list clears.
        let (path_str, line, col) = match parsed {
            Some((path, line, col)) if !path.is_empty() => (path, line, col),
            _ => {
                self.blank_overlay_preview();
                return;
            }
        };
        let line = line.unwrap_or(1).saturating_sub(1);
        let col = col.unwrap_or(1).saturating_sub(1);

        // Resolve relative to the working directory.
        let path_buf = std::path::PathBuf::from(&path_str);
        let abs_path = if path_buf.is_absolute() {
            path_buf
        } else {
            self.working_dir().join(&path_buf)
        };
        // Canonicalize for buffer-dedup parity with open_file_no_focus.
        let abs_path = self
            .authority()
            .filesystem
            .canonicalize(&abs_path)
            .unwrap_or(abs_path);

        // If the standalone state already targets this path, just
        // re-seed the cursor and skip the file-load roundtrip.
        let already_target = self
            .active_window()
            .overlay_preview_state
            .as_ref()
            .is_some_and(|st| {
                self.windows
                    .get(&self.active_window)
                    .map(|w| &w.buffers)
                    .expect("active window present")
                    .get(&st.buffer_id)
                    .and_then(|s| s.buffer.file_path())
                    .is_some_and(|p| p == abs_path.as_path())
            });

        let buffer_id = if already_target {
            self.active_window_mut()
                .overlay_preview_state
                .as_ref()
                .unwrap()
                .buffer_id
        } else {
            // Snapshot whether this path was already known so we can
            // tell "I just loaded it for preview" from "the user had
            // it open" — only the former gets cleaned up on close.
            let was_open = self
                .buffers()
                .iter()
                .any(|(_, s)| s.buffer.file_path() == Some(abs_path.as_path()));
            // Capture the active split so we can undo the side
            // effects of `open_file_no_focus` (it adds the buffer to
            // the active split's tabs and may switch its active
            // buffer to the loaded file).
            let source_split = self
                .windows
                .get(&self.active_window)
                .and_then(|w| w.buffers.splits())
                .map(|(mgr, _)| mgr)
                .expect("active window must have a populated split layout")
                .active_split();
            // `open_file_for_preview` always allocates a fresh buffer
            // — never repurposes the "no name" empty buffer the user
            // is currently looking at — so the background view stays
            // intact while we cycle through preview results.
            let buffer_id = match self.open_file_for_preview(abs_path.as_path()) {
                Ok(id) => id,
                Err(_e) => return,
            };
            if !was_open {
                if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
                    meta.hidden_from_tabs = true;
                }
                // Drop the buffer from every split's `open_buffers`
                // list so it doesn't surface as a tab anywhere. The
                // phantom buffer is rendered exclusively via the
                // overlay's standalone view-state — it doesn't need
                // to be in `open_buffers`.
                let leaf_ids: Vec<_> = self
                    .windows
                    .get(&self.active_window)
                    .and_then(|w| w.buffers.splits())
                    .map(|(_, vs)| vs)
                    .expect("active window must have a populated split layout")
                    .keys()
                    .copied()
                    .collect();
                for leaf_id in leaf_ids {
                    if let Some(view_state) = self
                        .windows
                        .get_mut(&self.active_window)
                        .and_then(|w| w.split_view_states_mut())
                        .expect("active window must have a populated split layout")
                        .get_mut(&leaf_id)
                    {
                        view_state.remove_buffer(buffer_id);
                    }
                }
                // open_file_no_focus may have switched the active
                // buffer of the source split. Restore it.
                let preview_loaded: std::collections::HashSet<BufferId> = self
                    .active_window_mut()
                    .overlay_preview_state
                    .as_ref()
                    .map(|st| st.loaded_buffers.clone())
                    .unwrap_or_default();
                let __active_id = self.active_window;
                let __win = self
                    .windows
                    .get_mut(&__active_id)
                    .expect("active window must exist");
                let __buffer_keys: Vec<BufferId> = __win.buffers.ids();
                let (__mgr, __vs_map) = __win
                    .buffers
                    .splits_mut()
                    .expect("active window must have a populated split layout");
                if let Some(source_state) = __vs_map.get_mut(&source_split) {
                    if source_state.active_buffer == buffer_id {
                        let fallback = source_state
                            .open_buffers
                            .iter()
                            .find_map(|t| t.as_buffer())
                            .or_else(|| {
                                __buffer_keys
                                    .iter()
                                    .copied()
                                    .find(|b| *b != buffer_id && !preview_loaded.contains(b))
                            });
                        if let Some(fb) = fallback {
                            source_state.switch_buffer(fb);
                            __mgr.set_split_buffer(source_split, fb);
                        }
                    }
                }
                self.windows
                    .get_mut(&self.active_window)
                    .and_then(|w| w.split_manager_mut())
                    .expect("active window must have a populated split layout")
                    .set_active_split(source_split);
            }
            buffer_id
        };

        // The buffer (if any) the preview pointed at on the previous
        // frame. When the selection moves to a result in a *different*
        // file we must drop our search-match overlays from the old
        // buffer (see the highlight refresh below).
        let prev_preview_buffer = self
            .active_window()
            .overlay_preview_state
            .as_ref()
            .map(|s| s.buffer_id);

        // Build (or update) the standalone preview state. Held off
        // `split_view_states` so cross-cutting iteration never touches
        // it.
        let need_init = self.active_window_mut().overlay_preview_state.is_none();
        if need_init {
            let mut view_state = crate::view::split::SplitViewState::with_buffer(
                self.terminal_width,
                self.terminal_height,
                buffer_id,
            );
            view_state.apply_config_defaults(crate::view::split::ViewConfigDefaults {
                line_numbers: self.config.editor.line_numbers,
                highlight_current_line: self.config.editor.highlight_current_line,
                line_wrap: self.active_window().resolve_line_wrap_for_buffer(buffer_id),
                wrap_indent: self.config.editor.wrap_indent,
                wrap_column: self
                    .active_window()
                    .resolve_wrap_column_for_buffer(buffer_id),
                rulers: self.config.editor.rulers.clone(),
                scroll_offset: self.config.editor.scroll_offset,
            });
            let mut loaded_buffers = std::collections::HashSet::new();
            // Whether this *first* preview buffer was newly loaded.
            // The pre-existing case skips the `was_open` branch so
            // we re-derive it from buffer_metadata: a buffer with
            // hidden_from_tabs=true that we just touched is one we
            // owned. Simpler: track via the existing-target check:
            // if `already_target` was false above, the buffer was
            // either pre-open (we left meta alone) or freshly
            // loaded (we set hidden_from_tabs=true). Re-check.
            if let Some(meta) = self.active_window().buffer_metadata.get(&buffer_id) {
                if meta.hidden_from_tabs {
                    loaded_buffers.insert(buffer_id);
                }
            }
            self.active_window_mut().overlay_preview_state =
                Some(crate::app::types::OverlayPreviewState {
                    buffer_id,
                    view_state,
                    loaded_buffers,
                    blanked: false,
                    centered_byte: None,
                });
        } else {
            // Pre-compute hidden flag (immutable borrow on self.windows)
            // before taking the mutable borrow on overlay_preview_state.
            let hidden_from_tabs = self
                .windows
                .get(&self.active_window)
                .and_then(|w| w.buffer_metadata.get(&buffer_id))
                .is_some_and(|meta| meta.hidden_from_tabs);
            if let Some(state) = self.active_window_mut().overlay_preview_state.as_mut() {
                if state.buffer_id != buffer_id {
                    state.view_state.switch_buffer(buffer_id);
                    // Keep the struct's `buffer_id` in lockstep with the
                    // view-state's active buffer: the renderer looks up the
                    // buffer to draw via this field, so a stale value here
                    // renders the *previous* file's text at the new file's
                    // scroll offset (wrong content, or blank past EOF).
                    state.buffer_id = buffer_id;
                    // New file in the preview ⇒ force a recenter below.
                    state.centered_byte = None;
                    if hidden_from_tabs {
                        state.loaded_buffers.insert(buffer_id);
                    }
                }
            }
        }

        // Set the cursor to the match position and centre it vertically.
        let byte_offset = self
            .buffers()
            .get(&buffer_id)
            .map(|s| {
                s.buffer
                    .position_to_offset(crate::model::piece_tree::Position { line, column: col })
            })
            .unwrap_or(0);

        // The overlay preview is used exclusively by the Live Grep
        // floating overlay, so the prompt input IS the search query.
        // Highlight every occurrence in the visible region — previously
        // the match was only reachable via the (hidden) cursor, which is
        // near-invisible against the preview chrome. Capture the query and
        // theme colours before the window borrow below.
        let query = self
            .active_window()
            .prompt
            .as_ref()
            .map(|p| p.input.clone())
            .unwrap_or_default();
        let (search_fg, search_bg) = {
            let theme = self.theme.read().unwrap();
            (theme.search_match_fg, theme.search_match_bg)
        };
        // Live Grep defaults to regex with smart-case (case-insensitive
        // unless the query carries an uppercase letter) — mirror that so
        // the highlight tracks what the search actually matched. A query
        // that isn't valid regex falls back to a literal match.
        let preview_regex = if query.is_empty() {
            None
        } else {
            let case_insensitive = !query.chars().any(|c| c.is_uppercase());
            regex::RegexBuilder::new(&query)
                .case_insensitive(case_insensitive)
                .build()
                .or_else(|_| {
                    regex::RegexBuilder::new(&regex::escape(&query))
                        .case_insensitive(case_insensitive)
                        .build()
                })
                .ok()
        };
        let preview_ns = crate::view::overlay::OverlayNamespace::from_string(
            "overlay-preview-search".to_string(),
        );

        let active_id = self.active_window;
        if let Some(win) = self.windows.get_mut(&active_id) {
            // `buffers` and `overlay_preview_state` are distinct fields, so
            // these mutable borrows are disjoint.
            let preview_buffer = win.buffers.get_mut(&buffer_id);
            let preview_state = win.overlay_preview_state.as_mut();
            if let (Some(state), Some(pstate)) = (preview_buffer, preview_state) {
                pstate.view_state.cursors.primary_mut().position = byte_offset;
                // Force line wrapping on for the preview regardless of the
                // global `editor.line_wrap` setting (and of a switched-in
                // buffer's fresh default): the preview pane has no
                // horizontal scroll affordance, so without wrapping a match
                // deep in a long line scrolls off-screen. Wrapping moots
                // horizontal scroll, so reset it to the left edge.
                // `view_state` derefs to the active buffer's
                // `BufferViewState`, so this targets the rendered buffer.
                pstate.view_state.viewport.line_wrap_enabled = true;
                // Recentre only when the selected match changed (issue
                // #2119) so a mouse-wheel scroll of the preview is
                // preserved; `center_on_position` counts real visual rows so
                // a match deep in a wrapped doc still lands mid-pane.
                if pstate.centered_byte != Some(byte_offset) {
                    pstate.view_state.viewport.left_column = 0;
                    pstate.view_state.viewport.horizontal_scroll_offset = 0;
                    pstate
                        .view_state
                        .viewport
                        .center_on_position(&mut state.buffer, byte_offset);
                    pstate.centered_byte = Some(byte_offset);
                }
                // We have a live target: ensure the pane is shown.
                pstate.blanked = false;

                // Rebuild the search-match overlays for the now-visible
                // region. Cleared + re-added every frame (cheap; bounded
                // to the viewport) so they track scrolling and edits, the
                // same contract `Window::update_search_highlights` uses.
                state
                    .overlays
                    .clear_namespace(&preview_ns, &mut state.marker_list);
                if let Some(re) = &preview_regex {
                    let visible_start = pstate.view_state.viewport.top_byte();
                    let visible_rows = pstate.view_state.viewport.height as usize;
                    let mut visible_end = visible_start;
                    {
                        let mut iter = state.buffer.line_iterator(visible_start, 80);
                        for _ in 0..visible_rows {
                            if let Some((line_start, line_content)) = iter.next_line() {
                                visible_end = line_start + line_content.len();
                            } else {
                                break;
                            }
                        }
                    }
                    visible_end = visible_end.min(state.buffer.len());
                    let visible_text = state.get_text_range(visible_start, visible_end);
                    for mat in re.find_iter(&visible_text) {
                        if mat.start() == mat.end() {
                            continue;
                        }
                        let absolute_pos = visible_start + mat.start();
                        let match_len = mat.end() - mat.start();
                        let style = ratatui::style::Style::default().fg(search_fg).bg(search_bg);
                        let overlay = crate::view::overlay::Overlay::with_namespace(
                            &mut state.marker_list,
                            absolute_pos..(absolute_pos + match_len),
                            crate::view::overlay::OverlayFace::Style { style },
                            preview_ns.clone(),
                        )
                        .with_priority_value(10);
                        state.overlays.add(overlay);
                    }
                }
            }

            // The selection jumped to a result in a different file: scrub
            // our overlays from the previously-previewed buffer. Matters
            // only for buffers the user already had open — preview-loaded
            // buffers are closed wholesale on overlay teardown.
            if let Some(prev) = prev_preview_buffer {
                if prev != buffer_id {
                    if let Some(prev_state) = win.buffers.get_mut(&prev) {
                        prev_state
                            .overlays
                            .clear_namespace(&preview_ns, &mut prev_state.marker_list);
                    }
                }
            }
        }
    }

    /// Blank the Live Grep preview pane: it renders just its frame until
    /// the next selectable result. Keeps `overlay_preview_state` (and its
    /// `loaded_buffers` cleanup tracking) intact.
    fn blank_overlay_preview(&mut self) {
        if let Some(state) = self.active_window_mut().overlay_preview_state.as_mut() {
            state.blanked = true;
        }
    }

    /// Render the active prompt as a centred floating overlay
    /// (issue #1796). Layout, top-down inside the overlay frame:
    ///
    /// ```text
    /// ┌─ Live Grep ──────────────────────────────────[Esc to close]┐
    /// │ Search: split_active|                           12 / 142    │  ← input row
    /// │ ─────────────────────────────────────────────────────────── │
    /// │  src/view/split.rs:1117  pub fn split_active(    │ preview │  ← results
    /// │  src/view/split.rs:1123  self.split_active_pos…  │  pane   │     (+ optional
    /// │ …                                                │         │      preview)
    /// └────────────────────────────────────────────────────────────┘
    /// ```
    ///
    /// The overlay does *not* mutate the split tree; it is a pure
    /// `ratatui` overdraw, so dismissing leaves the user's underlying
    /// layout exactly as it was (the issue-#1796 acceptance test).
    fn render_overlay_prompt(&mut self, frame: &mut Frame, area: ratatui::layout::Rect) {
        use ratatui::layout::Rect;
        use ratatui::style::{Modifier, Style};
        use ratatui::text::{Line, Span};
        use ratatui::widgets::{Block, Borders, Clear, Paragraph};

        // Compute the overlay rect via the same percentage logic the
        // popup engine uses. 90% × 90% of the terminal, centred.
        let overlay_rect = Self::centered_overlay_rect(area, 90, 90);

        // Snapshot view-relevant state before any mutable borrows.
        let theme = self.theme.read().unwrap().clone();
        // The suggestion list inside the overlay can be ~30 rows
        // tall on a typical terminal. Pass the *actual* visible
        // count to `ensure_selected_visible_within` so the scroll
        // offset only advances when the selection genuinely passes
        // the bottom of the visible window — not when it crosses
        // the bottom-popup default cap of `MAX_VISIBLE_SUGGESTIONS`
        // (= 10), which would scroll prematurely.
        //
        // Geometry: overlay frame border (2) + input row (1) +
        // optional toolbar row (1, when `prompt.title` is non-empty)
        // + separator (1). The suggestions popup is rendered
        // borderless inside the overlay (the outer frame already
        // provides a border, so adding a nested one creates a
        // double-frame). Inner content height = overlay.height -
        // chrome.
        // Toolbar height must be the *actual* rendered row count — a widget
        // toolbar is ≥2 rows (e.g. "Search in:" + "Match:") and wraps to more
        // on a narrow terminal. Measuring it (vs assuming 1) keeps
        // `suggestions_visible_rows` honest, so `ensure_selected_visible`
        // doesn't let the selection scroll just past the real list bottom.
        let inner_w = overlay_rect.width.saturating_sub(2);
        let toolbar_rows: usize = self
            .active_window()
            .prompt
            .as_ref()
            .map(|p| {
                if let Some(spec) = p.toolbar_widget.as_ref() {
                    crate::widgets::render_spec_no_autofocus(
                        spec,
                        &std::collections::HashMap::new(),
                        p.toolbar_focus.as_deref().unwrap_or(""),
                        inner_w as u32,
                    )
                    .entries
                    .len()
                } else if p.title.is_empty() {
                    0
                } else {
                    1
                }
            })
            .unwrap_or(0);
        let footer_visible = self
            .active_window()
            .prompt
            .as_ref()
            .map(|p| !p.footer.is_empty())
            .unwrap_or(false);
        // Chrome around the result list: frame border (2) + input (1) +
        // separator (1) + toolbar (`toolbar_rows`) + optional full-width footer (1).
        let chrome_rows: usize = 4 + toolbar_rows + usize::from(footer_visible);
        let suggestions_visible_rows = (overlay_rect.height as usize).saturating_sub(chrome_rows);
        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
            // Skip when the user has wheel-scrolled the list — keeping the
            // selection pinned in view would undo their scroll (issue #2119).
            if !prompt.manual_scroll {
                prompt.ensure_selected_visible_within(suggestions_visible_rows);
            }
        }
        let Some(prompt) = self.active_window().prompt.as_ref() else {
            return;
        };
        let prompt = prompt.clone();

        // Layout-vs-draw seam: when a frontend renders this overlay itself
        // (the web renders it natively from `PaletteView`), we still compute all
        // geometry/caches below but paint NO cells — so there's nothing to bleed
        // behind the native card. For the TUI `draw` is always true, so its path
        // is unchanged (every guard below is a no-op).
        let draw = !self.suppress_chrome_cells;

        // Dim everything outside the overlay rect so the user's
        // focus visibly belongs to the popup. Reuses the same RGB-
        // darkening pass the Settings modal uses (`view::dimming`)
        // — Modifier::DIM alone is barely visible on most terminals.
        if draw {
            crate::view::dimming::apply_dimming_excluding(frame, frame.area(), Some(overlay_rect));
        }

        // Clear and frame. Plugin-owned prompts can publish their
        // own title via `editor.setPromptTitle(...)`; falls back to
        // " Live Grep " plus shortcut hints when unset (so a
        // Resume-replay prompt and freshly-opened plugin prompt look
        // similar even though they take different code paths).
        if draw {
            frame.render_widget(Clear, overlay_rect);
        }
        let default_title: Vec<fresh_core::api::StyledText> = {
            // Mirrors `updateOverlayTitle` in live_grep.ts (kept in
            // sync deliberately so a Resume-replay overlay and a
            // freshly-opened plugin overlay look identical). The
            // input row's prefix already says "Live grep:", so the
            // frame title doesn't repeat the feature name — it
            // shows shortcut hints only. `resume_live_grep` is
            // intentionally NOT shown here; that shortcut only
            // matters once the overlay is closed.
            use crate::input::keybindings::KeyContext;
            use fresh_core::api::{OverlayColorSpec, OverlayOptions, StyledText};
            let keybindings = self.keybindings.read().unwrap();
            let mut hints: Vec<(String, &str)> = Vec::new();
            if let Some(k) = keybindings
                .find_keybinding_for_action("cycle_live_grep_provider", KeyContext::Prompt)
            {
                hints.push((k, "switch grep provider"));
            }
            if let Some(k) = keybindings
                .find_keybinding_for_action("live_grep_export_quickfix", KeyContext::Prompt)
            {
                hints.push((k, "save matches"));
            }
            if hints.is_empty() {
                Vec::new()
            } else {
                let hint_style = Some(OverlayOptions {
                    fg: Some(OverlayColorSpec::ThemeKey("ui.help_key_fg".into())),
                    ..OverlayOptions::default()
                });
                let sep_style = Some(OverlayOptions {
                    fg: Some(OverlayColorSpec::ThemeKey("ui.popup_border_fg".into())),
                    ..OverlayOptions::default()
                });
                let mut segs: Vec<StyledText> = Vec::new();
                for (i, (k, verb)) in hints.into_iter().enumerate() {
                    if i > 0 {
                        segs.push(StyledText {
                            text: " · ".into(),
                            style: sep_style.clone(),
                        });
                    }
                    segs.push(StyledText {
                        text: k,
                        style: hint_style.clone(),
                    });
                    segs.push(StyledText {
                        text: format!(" {verb}"),
                        style: None,
                    });
                }
                segs
            }
        };
        let title_segs: &[fresh_core::api::StyledText] = if prompt.title.is_empty() {
            &default_title
        } else {
            &prompt.title
        };
        let normal_title_style = Style::default()
            .fg(theme.prompt_fg)
            .add_modifier(Modifier::BOLD);
        let title_spans: Vec<Span> = title_segs
            .iter()
            .map(|seg| {
                let style = match &seg.style {
                    Some(opts) => Self::resolve_overlay_style(opts, &theme),
                    None => normal_title_style,
                };
                Span::styled(seg.text.clone(), style)
            })
            .collect();
        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(theme.popup_border_fg))
            .style(Style::default().bg(theme.suggestion_bg));
        let inner = block.inner(overlay_rect);
        if draw {
            frame.render_widget(block, overlay_rect);
        }

        if inner.height == 0 || inner.width == 0 {
            return;
        }

        // If the plugin supplied a widget toolbar, render it now (full inner
        // width) so we know its height before laying out the header band. The
        // toggles are real `Toggle` widgets — themed and clickable — rather
        // than styled text. `render_spec` is stateless here (empty prior
        // state / no focus key): a `Toggle`'s checked-ness lives in the spec,
        // and click-to-toggle is routed by key (no registry needed).
        let toolbar_focus_key = prompt.toolbar_focus.as_deref().unwrap_or("");
        let toolbar_widget_out: Option<crate::widgets::RenderOutput> =
            prompt.toolbar_widget.as_ref().map(|spec| {
                crate::widgets::render_spec_no_autofocus(
                    spec,
                    &std::collections::HashMap::new(),
                    toolbar_focus_key,
                    inner.width as u32,
                )
            });

        // Layout: a full-width HEADER band (input + toolbar + separator)
        // spans the whole inner width at the top; the BODY below it splits
        // into results | preview; a full-width FOOTER (when the plugin set
        // one) sits at the very bottom. This gives the toolbar the entire
        // pane width — the scope checkboxes don't fit when squeezed into the
        // left half beside the preview — and places the preview *under* the
        // toolbar, side-by-side with the result list. See
        // docs/internal/global-search-ux.md §12.
        let toolbar_h: u16 = match &toolbar_widget_out {
            Some(out) => out.entries.len() as u16,
            None if !prompt.title.is_empty() => 1,
            None => 0,
        };
        let footer_h: u16 = if prompt.footer.is_empty() { 0 } else { 1 };
        // Header rows = input(1) + toolbar(toolbar_h) + separator(1).
        let header_h: u16 = 2 + toolbar_h;
        let body = Rect {
            x: inner.x,
            y: inner.y.saturating_add(header_h),
            width: inner.width,
            height: inner.height.saturating_sub(header_h + footer_h),
        };

        // Split the body into results | preview. Below ~120 cols, stack
        // results-only (preview hidden — see design doc §5 "preview pane size
        // when terminal is narrow").
        let preview_min_cols: u16 = 120;
        let show_preview = overlay_rect.width >= preview_min_cols && body.height > 0;
        let (results_area, preview_area) = if show_preview {
            let results_w = body.width / 2;
            (
                Rect {
                    x: body.x,
                    y: body.y,
                    width: results_w,
                    height: body.height,
                },
                Some(Rect {
                    x: body.x + results_w,
                    y: body.y,
                    width: body.width - results_w,
                    height: body.height,
                }),
            )
        } else {
            (body, None)
        };

        // Cache the result/preview rects so the mouse-wheel handler can route
        // the wheel to the pane under the pointer (issue #2119).
        self.active_chrome_mut().prompt_results_area = Some(results_area);
        self.active_chrome_mut().prompt_preview_area = preview_area;

        // The prompt input is the full-width top row of the header band.
        let input_row = Rect {
            x: inner.x,
            y: inner.y,
            width: inner.width,
            height: 1,
        };
        // Two distinct styles on this row so the user can tell
        // the static title (`prompt.message`) apart from the
        // editable input field. Title gets the popup-chrome bg
        // (matching the toolbar/footer); input + right-side
        // padding + count get the editor bg so they read as one
        // contiguous text field. All colours from theme keys.
        let title_style = Style::default()
            .fg(theme.suggestion_fg)
            .bg(theme.suggestion_bg);
        let input_style = Style::default().fg(theme.editor_fg).bg(theme.editor_bg);
        let count_str = if prompt.suggestions.is_empty() {
            String::new()
        } else {
            format!(
                "{} / {}",
                prompt.selected_suggestion.map(|i| i + 1).unwrap_or(0),
                prompt.suggestions.len()
            )
        };
        use crate::primitives::display_width::str_width;
        let count_w = str_width(&count_str);
        // Reserve one trailing column so the count doesn't sit
        // flush against the right border.
        let right_gap: usize = if count_w > 0 { 1 } else { 0 };
        // Right cluster: "<status>  <count>" — the plugin's search status
        // (e.g. "Searching…", "No matches") sits just left of the count, so
        // it's on the same row the user is typing on rather than a wasted
        // chrome row. Two-space gap between status and count when both show.
        let status_str = prompt.status.clone();
        let status_w = str_width(&status_str);
        let status_gap: usize = if status_w > 0 && count_w > 0 { 2 } else { 0 };
        let right_cluster_w = status_w + status_gap + count_w + right_gap;
        let visible_input_width = (input_row.width as usize).saturating_sub(right_cluster_w);
        let truncated_input: String = prompt
            .input
            .chars()
            .take(visible_input_width.saturating_sub(str_width(&prompt.message)))
            .collect();
        // Pad between the typed input and the right cluster so the count is
        // right-aligned (with `right_gap` empty cols at the very edge),
        // independent of how much the user has typed.
        let used = str_width(&prompt.message) + str_width(&truncated_input) + right_cluster_w;
        let pad = (input_row.width as usize).saturating_sub(used);
        let dim = Style::default()
            .fg(theme.popup_border_fg)
            .bg(theme.editor_bg);
        let line = Line::from(vec![
            Span::styled(prompt.message.clone(), title_style),
            Span::styled(truncated_input, input_style),
            Span::styled(" ".repeat(pad), input_style),
            Span::styled(status_str, dim),
            Span::styled(" ".repeat(status_gap), input_style),
            Span::styled(count_str, dim),
        ]);
        if draw {
            frame.render_widget(Paragraph::new(line).style(input_style), input_row);
        }

        // Cursor position on the input row — only when the input is focused.
        // When a toolbar control owns focus, the highlighted toggle is the
        // focus indicator and the input caret would be misleading.
        let input_focused = prompt.toolbar_focus.is_none();
        let cursor_x = (str_width(&prompt.message)
            + str_width(&prompt.input[..prompt.cursor_pos.min(prompt.input.len())]))
            as u16;
        if draw && input_focused && cursor_x < input_row.width {
            frame.set_cursor_position((input_row.x + cursor_x, input_row.y));
        }

        // Optional toolbar row (the styled segments the plugin set
        // via setPromptTitle, e.g. "Provider: rg · Alt+P switch
        // grep provider · …"). Sits between the input row and the
        // separator so the user sees feature-scoped controls right
        // under what they're typing — not on the frame border
        // where shortcut hints get visually lost.
        self.active_chrome_mut().prompt_toolbar_hits.clear();
        if let Some(out) = &toolbar_widget_out {
            // Widget toolbar: paint each rendered row across the full width
            // and record screen-space hit rects (key → rect) for click
            // routing. `HitArea` carries byte offsets within the row's text;
            // convert to display columns so the rect lines up with the glyphs.
            use crate::primitives::display_width::str_width;
            let band_y = inner.y + 1;
            if draw {
                for (i, entry) in out.entries.iter().enumerate() {
                    let y = band_y + i as u16;
                    if y >= inner.y + inner.height {
                        break;
                    }
                    paint_text_property_entry(frame, entry, inner.x, y, inner.width, &theme, None);
                }
            }
            for hit in &out.hits {
                if hit.widget_key.is_empty() {
                    continue;
                }
                let Some(entry) = out.entries.get(hit.buffer_row as usize) else {
                    continue;
                };
                let text = &entry.text;
                let start_col = str_width(text.get(..hit.byte_start).unwrap_or(""));
                let end_col = str_width(text.get(..hit.byte_end).unwrap_or(text));
                let y = band_y + hit.buffer_row as u16;
                let rect = Rect {
                    x: inner.x + start_col as u16,
                    y,
                    width: (end_col.saturating_sub(start_col)) as u16,
                    height: 1,
                };
                self.active_chrome_mut()
                    .prompt_toolbar_hits
                    .push((hit.widget_key.clone(), rect));
            }
        } else if draw && !prompt.title.is_empty() && inner.height >= 2 {
            let toolbar = Rect {
                x: inner.x,
                y: inner.y + 1,
                width: inner.width,
                height: 1,
            };
            frame.render_widget(
                Paragraph::new(Line::from(title_spans))
                    .style(Style::default().bg(theme.suggestion_bg)),
                toolbar,
            );
        }

        // Separator row (full width), closing the header band.
        if draw && inner.height >= 2 + toolbar_h {
            let sep = Rect {
                x: inner.x,
                y: inner.y + 1 + toolbar_h,
                width: inner.width,
                height: 1,
            };
            let sep_style = Style::default()
                .fg(theme.popup_border_fg)
                .bg(theme.suggestion_bg);
            let sep_text = "─".repeat(inner.width as usize);
            frame.render_widget(Paragraph::new(sep_text).style(sep_style), sep);
        }

        // Suggestions list fills `results_area` (the left half of the body)
        // entirely — the input, toolbar and separator now live in the header
        // band above, and the footer is a separate full-width row below, so
        // there's no in-column chrome to subtract here. Carve off the
        // rightmost 1-column lane for a scrollbar so the user can see how far
        // through the result set the selection is — only when the result set
        // actually exceeds the visible rows; otherwise the scrollbar is
        // visual noise.
        if results_area.height >= 1 {
            // No `-2` for popup-own-border — we render the
            // borderless variant below since the overlay frame is
            // already a border.
            let inner_rows = results_area.height as usize;
            let needs_scrollbar = prompt.suggestions.len() > inner_rows.max(1);
            let scrollbar_w: u16 = if needs_scrollbar { 1 } else { 0 };
            let list_area = Rect {
                x: results_area.x,
                y: results_area.y,
                width: results_area.width.saturating_sub(scrollbar_w),
                height: results_area.height,
            };
            let draw_chrome = !self.suppress_chrome_cells;
            self.active_chrome_mut().suggestions_area = SuggestionsRenderer::render_with_hover(
                frame,
                list_area,
                &prompt,
                &theme,
                self.active_window_mut().mouse_state.hover_target.as_ref(),
                false,
                draw_chrome,
            );
            if self.active_chrome_mut().suggestions_area.is_some() {
                self.active_chrome_mut().suggestions_outer_area = Some(list_area);
            }
            // Render the scrollbar in the carved lane. Reuses the
            // shared `view::ui::scrollbar` widget so thumb sizing
            // and theme colours match scrollbars elsewhere in the
            // editor (split rendering, file explorer, …).
            if needs_scrollbar {
                use crate::view::ui::scrollbar::{
                    render_scrollbar, ScrollbarColors, ScrollbarState,
                };
                // Scrollbar rect aligns with the borderless
                // suggestions list — same y/height as the list itself
                // since there's no popup-own border to skip.
                let scrollbar_rect = Rect {
                    x: results_area.x + results_area.width - 1,
                    y: list_area.y,
                    width: 1,
                    height: list_area.height,
                };
                let state = ScrollbarState::new(
                    prompt.suggestions.len(),
                    inner_rows.max(1),
                    prompt.scroll_offset,
                );
                if draw {
                    render_scrollbar(
                        frame,
                        scrollbar_rect,
                        &state,
                        &ScrollbarColors::from_theme(&theme),
                    );
                }
                // Cache the rect for mouse hit testing in
                // `mouse_input.rs::handle_click_prompt_scrollbar`.
                self.active_chrome_mut().suggestions_scrollbar_rect = Some(scrollbar_rect);
            } else {
                self.active_chrome_mut().suggestions_scrollbar_rect = None;
            }
        } else {
            self.active_chrome_mut().suggestions_scrollbar_rect = None;
        }

        // Plugin-supplied footer chrome row (Primitive #2 chrome
        // region). Each segment is a `StyledText` — same styling
        // primitive used by `setPromptTitle` and inline overlays,
        // so plugins can theme hotkey hints with `ui.help_key_fg`,
        // separators with `ui.popup_border_fg`, etc.
        if draw && footer_h == 1 && inner.height >= 1 {
            let footer_row = Rect {
                x: inner.x,
                y: inner.y + inner.height - 1,
                width: inner.width,
                height: 1,
            };
            let footer_default_style = Style::default()
                .fg(theme.suggestion_fg)
                .bg(theme.suggestion_bg);
            let footer_spans: Vec<Span> = prompt
                .footer
                .iter()
                .map(|seg| {
                    let style = match &seg.style {
                        Some(opts) => Self::resolve_overlay_style(opts, &theme),
                        None => footer_default_style,
                    };
                    Span::styled(seg.text.clone(), style)
                })
                .collect();
            frame.render_widget(
                Paragraph::new(Line::from(footer_spans))
                    .style(Style::default().bg(theme.suggestion_bg)),
                footer_row,
            );
        }

        // Right-half preview pane: a real Buffer rendered via the
        // same per-leaf pipeline regular splits use. Buffer + cursor
        // are already seeded by `prepare_overlay_preview` (called
        // earlier in the render flow). Borrows are split here so we
        // can hand out independent `&mut` references to the
        // renderer's internals without going back through `&mut self`.
        if let Some(preview_rect) = preview_area {
            // Frame the preview area (vertical separator) so the renderer fills
            // the inner rect. The frame is *chrome* — drawn only for the TUI;
            // the web draws its own border in HTML. The buffer *content* below,
            // however, is real rendered cells (like a pane interior), so it is
            // drawn for both frontends and the web slices it from the buffer.
            use ratatui::widgets::{Block, Borders, Clear};
            let block = Block::default()
                .borders(Borders::LEFT)
                .border_style(Style::default().fg(theme.popup_border_fg))
                .style(Style::default().bg(theme.suggestion_bg));
            let inner = block.inner(preview_rect);
            if draw {
                frame.render_widget(Clear, preview_rect);
                frame.render_widget(block, preview_rect);
            }

            // Primitive #1: if the active plugin asked us to
            // preview a specific (inactive) session in this
            // rect, render that session's entire stashed split
            // tree natively into `inner`. Falls back to the
            // existing path-based phantom-leaf preview when no
            // session override is set.
            if inner.height > 0
                && inner.width > 0
                && self
                    .preview_window_id
                    .is_some_and(|sid| sid != self.active_window && self.windows.contains_key(&sid))
            {
                self.render_session_preview_into_rect(frame, inner, &theme);
            } else if inner.height > 0 && inner.width > 0 {
                // Snapshot the per-split scalars and group the appearance
                // inputs into one `RenderStyle`, all before the `&mut
                // self.windows` borrow below — they touch only
                // `self.config`/`self.theme`/`self.ansi_background`, which Rust
                // splits from `self.windows` as distinct fields.
                let session_mode = self.session_mode || !self.software_cursor_only;
                let show_tilde = false; // preview hides tilde markers
                let highlight_current_column = self.config.editor.highlight_current_column;
                let screen_width = frame.area().width;
                let style = crate::view::ui::RenderStyle {
                    theme: &theme,
                    ansi_background: self.ansi_background.as_ref(),
                    cfg: crate::view::ui::EditorRenderConfig::new(
                        &self.config.editor,
                        self.background_fade,
                        self.software_cursor_only,
                    ),
                };
                let __win = self
                    .windows
                    .get_mut(&self.active_window)
                    .expect("active window present");
                let buffers = &mut __win.buffers;
                let event_logs = &mut __win.event_logs;
                let cell_theme_map = &mut __win.chrome_layout.cell_theme_map;
                let Some(preview_state) = __win.overlay_preview_state.as_mut() else {
                    return;
                };
                // Blanked: the current query has no selectable result, so
                // leave the framed pane empty rather than rendering a stale
                // match.
                if preview_state.blanked {
                    return;
                }
                preview_state
                    .view_state
                    .viewport
                    .resize(inner.width, inner.height);
                let buffer_id = preview_state.buffer_id;

                if let Some(state) = buffers.get_mut(&buffer_id) {
                    // Deref the SplitViewState once to a concrete
                    // `&mut BufferViewState` so disjoint field
                    // splits (`viewport` + `folds`) are visible
                    // to the borrow checker.
                    let buf_state = preview_state.view_state.active_state_mut();
                    let cursors = buf_state.cursors.clone();
                    let view_mode = buf_state.view_mode.clone();
                    let compose_width = buf_state.compose_width;
                    let compose_column_guides = buf_state.compose_column_guides.clone();
                    let rulers = buf_state.rulers.clone();
                    let show_line_numbers = buf_state.show_line_numbers;
                    let highlight_current_line = buf_state.highlight_current_line;
                    let viewport_ref = &mut buf_state.viewport;
                    let folds_ref = &mut buf_state.folds;
                    let event_log = event_logs.get_mut(&buffer_id);
                    let _ = crate::view::ui::SplitRenderer::render_phantom_leaf(
                        frame.buffer_mut(),
                        state,
                        &cursors,
                        viewport_ref,
                        folds_ref,
                        event_log,
                        inner,
                        style,
                        view_mode,
                        compose_width,
                        compose_column_guides,
                        buffer_id,
                        session_mode,
                        &rulers,
                        show_line_numbers,
                        highlight_current_line,
                        show_tilde,
                        highlight_current_column,
                        cell_theme_map,
                        screen_width,
                    );
                }
            }
        }
    }

    /// Render hover highlights for interactive elements (separators, scrollbars)
    pub(super) fn render_hover_highlights(&self, frame: &mut Frame) {
        use ratatui::style::Style;
        use ratatui::text::Span;
        use ratatui::widgets::Paragraph;

        match &self.active_window().mouse_state.hover_target {
            Some(HoverTarget::SplitSeparator(split_id, direction)) => {
                // Highlight the separator with hover color
                for (sid, dir, x, y, length) in &self.active_layout().separator_areas {
                    if sid == split_id && dir == direction {
                        let (hover_fg, editor_bg) = {
                            let theme = self.theme.read().unwrap();
                            (theme.split_separator_hover_fg, theme.editor_bg)
                        };
                        let hover_style = Style::default().fg(hover_fg).bg(editor_bg);
                        match dir {
                            SplitDirection::Horizontal => {
                                let line_text = "─".repeat(*length as usize);
                                let paragraph =
                                    Paragraph::new(Span::styled(line_text, hover_style));
                                frame.render_widget(
                                    paragraph,
                                    ratatui::layout::Rect::new(*x, *y, *length, 1),
                                );
                            }
                            SplitDirection::Vertical => {
                                for offset in 0..*length {
                                    let paragraph = Paragraph::new(Span::styled("│", hover_style));
                                    frame.render_widget(
                                        paragraph,
                                        ratatui::layout::Rect::new(*x, y + offset, 1, 1),
                                    );
                                }
                            }
                        }
                    }
                }
            }
            Some(HoverTarget::ScrollbarThumb(split_id)) => {
                // Highlight scrollbar thumb
                for (sid, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
                    &self.active_layout().split_areas
                {
                    if sid == split_id {
                        let hover_style = Style::default().bg(self
                            .theme
                            .read()
                            .unwrap()
                            .scrollbar_thumb_hover_fg);
                        for row_offset in *thumb_start..*thumb_end {
                            let paragraph = Paragraph::new(Span::styled(" ", hover_style));
                            frame.render_widget(
                                paragraph,
                                ratatui::layout::Rect::new(
                                    scrollbar_rect.x,
                                    scrollbar_rect.y + row_offset as u16,
                                    1,
                                    1,
                                ),
                            );
                        }
                    }
                }
            }
            Some(HoverTarget::ScrollbarTrack(split_id, hovered_row)) => {
                // Highlight only the hovered cell on the scrollbar track
                for (sid, _buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
                    &self.active_layout().split_areas
                {
                    if sid == split_id {
                        let track_hover_style = Style::default().bg(self
                            .theme
                            .read()
                            .unwrap()
                            .scrollbar_track_hover_fg);
                        let paragraph = Paragraph::new(Span::styled(" ", track_hover_style));
                        frame.render_widget(
                            paragraph,
                            ratatui::layout::Rect::new(
                                scrollbar_rect.x,
                                scrollbar_rect.y + hovered_row,
                                1,
                                1,
                            ),
                        );
                    }
                }
            }
            Some(HoverTarget::FileExplorerBorder) => {
                // Highlight the file explorer border for resize
                if let Some(explorer_area) = self.active_layout().file_explorer_area {
                    let (hover_fg, editor_bg) = {
                        let theme = self.theme.read().unwrap();
                        (theme.split_separator_hover_fg, theme.editor_bg)
                    };
                    let hover_style = Style::default().fg(hover_fg).bg(editor_bg);
                    let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
                    for row_offset in 0..explorer_area.height {
                        let paragraph = Paragraph::new(Span::styled("│", hover_style));
                        frame.render_widget(
                            paragraph,
                            ratatui::layout::Rect::new(
                                border_x,
                                explorer_area.y + row_offset,
                                1,
                                1,
                            ),
                        );
                    }
                }
            }
            // Menu hover is handled by MenuRenderer
            _ => {}
        }
    }

    /// Render a native context menu (tab / "+" new-tab / file-explorer) from
    /// its shared geometry core and pre-resolved item labels.
    ///
    /// All three menus draw an identical bordered list — a `Clear`ed box, one
    /// padded row per item, the highlighted row in the highlight colours — so
    /// this is the single renderer for every one of them. The `ContextMenu`
    /// core supplies the fixed width and the edge-clamped position, keeping
    /// the drawn box aligned with the hover/click hit-test that reads the same
    /// core.
    fn render_context_menu(
        &self,
        frame: &mut Frame,
        core: &super::types::ContextMenu,
        items: &[String],
    ) {
        use ratatui::style::Style;
        use ratatui::text::{Line, Span};
        use ratatui::widgets::{Block, Borders, Clear, Paragraph};

        let menu_width = core.width;
        let menu_height = core.height();
        let (menu_x, menu_y) = core.clamped_position(frame.area().width, frame.area().height);

        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);

        // Clear the area first so the menu box paints over the content beneath.
        frame.render_widget(Clear, area);

        let (highlight_fg, highlight_bg, dropdown_fg, dropdown_bg, border_fg) = {
            let theme = self.theme.read().unwrap();
            (
                theme.menu_highlight_fg,
                theme.menu_highlight_bg,
                theme.menu_dropdown_fg,
                theme.menu_dropdown_bg,
                theme.menu_border_fg,
            )
        };

        let mut lines = Vec::new();
        for (idx, label) in items.iter().enumerate() {
            let style = if idx == core.highlighted {
                Style::default().fg(highlight_fg).bg(highlight_bg)
            } else {
                Style::default().fg(dropdown_fg).bg(dropdown_bg)
            };

            // Pad the label to fill the menu width (minus the two border cells).
            let content_width = (menu_width as usize).saturating_sub(2);
            let padded_label = format!(" {:<width$}", label, width = content_width - 1);

            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
        }

        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(border_fg))
            .style(Style::default().bg(dropdown_bg));

        let paragraph = Paragraph::new(lines).block(block);
        frame.render_widget(paragraph, area);
    }

    /// Render the tab drag drop zone overlay
    fn render_tab_drop_zone(&self, frame: &mut Frame, drag_state: &super::types::TabDragState) {
        use ratatui::style::Modifier;

        let Some(ref drop_zone) = drag_state.drop_zone else {
            return;
        };

        let split_id = drop_zone.split_id();

        // Find the content area for the target split
        let split_area = self
            .active_layout()
            .split_areas
            .iter()
            .find(|(sid, _, _, _, _, _)| *sid == split_id)
            .map(|(_, _, content_rect, _, _, _)| *content_rect);

        let Some(content_rect) = split_area else {
            return;
        };

        // Determine the highlight area based on drop zone type
        use super::types::TabDropZone;

        let highlight_area = match drop_zone {
            TabDropZone::TabBar(_, _) | TabDropZone::SplitCenter(_) => {
                // For tab bar and center drops, highlight the entire split area
                // This indicates the tab will be added to this split's tab bar
                content_rect
            }
            TabDropZone::SplitLeft(_) => {
                // Left 50% of the split (matches the actual split size created)
                let width = (content_rect.width / 2).max(3);
                ratatui::layout::Rect::new(
                    content_rect.x,
                    content_rect.y,
                    width,
                    content_rect.height,
                )
            }
            TabDropZone::SplitRight(_) => {
                // Right 50% of the split (matches the actual split size created)
                let width = (content_rect.width / 2).max(3);
                let x = content_rect.x + content_rect.width - width;
                ratatui::layout::Rect::new(x, content_rect.y, width, content_rect.height)
            }
            TabDropZone::SplitTop(_) => {
                // Top 50% of the split (matches the actual split size created)
                let height = (content_rect.height / 2).max(2);
                ratatui::layout::Rect::new(
                    content_rect.x,
                    content_rect.y,
                    content_rect.width,
                    height,
                )
            }
            TabDropZone::SplitBottom(_) => {
                // Bottom 50% of the split (matches the actual split size created)
                let height = (content_rect.height / 2).max(2);
                let y = content_rect.y + content_rect.height - height;
                ratatui::layout::Rect::new(content_rect.x, y, content_rect.width, height)
            }
        };

        // Draw the overlay with the drop zone color
        // We apply a semi-transparent effect by modifying existing cells
        let buf = frame.buffer_mut();
        let drop_zone_bg = self.theme.read().unwrap().tab_drop_zone_bg;
        let drop_zone_border = self.theme.read().unwrap().tab_drop_zone_border;

        // Fill the highlight area with a semi-transparent overlay
        for y in highlight_area.y..highlight_area.y + highlight_area.height {
            for x in highlight_area.x..highlight_area.x + highlight_area.width {
                if let Some(cell) = buf.cell_mut((x, y)) {
                    // Blend the drop zone color with the existing background
                    // For a simple effect, we just set the background
                    cell.set_bg(drop_zone_bg);

                    // Draw border on edges
                    let is_border = x == highlight_area.x
                        || x == highlight_area.x + highlight_area.width - 1
                        || y == highlight_area.y
                        || y == highlight_area.y + highlight_area.height - 1;

                    if is_border {
                        cell.set_fg(drop_zone_border);
                        cell.set_style(cell.style().add_modifier(Modifier::BOLD));
                    }
                }
            }
        }

        // Draw a border indicator based on the zone type
        match drop_zone {
            TabDropZone::SplitLeft(_) => {
                // Draw vertical indicator on left edge
                for y in highlight_area.y..highlight_area.y + highlight_area.height {
                    if let Some(cell) = buf.cell_mut((highlight_area.x, y)) {
                        cell.set_symbol("▌");
                        cell.set_fg(drop_zone_border);
                    }
                }
            }
            TabDropZone::SplitRight(_) => {
                // Draw vertical indicator on right edge
                let x = highlight_area.x + highlight_area.width - 1;
                for y in highlight_area.y..highlight_area.y + highlight_area.height {
                    if let Some(cell) = buf.cell_mut((x, y)) {
                        cell.set_symbol("▐");
                        cell.set_fg(drop_zone_border);
                    }
                }
            }
            TabDropZone::SplitTop(_) => {
                // Draw horizontal indicator on top edge
                for x in highlight_area.x..highlight_area.x + highlight_area.width {
                    if let Some(cell) = buf.cell_mut((x, highlight_area.y)) {
                        cell.set_symbol("▀");
                        cell.set_fg(drop_zone_border);
                    }
                }
            }
            TabDropZone::SplitBottom(_) => {
                // Draw horizontal indicator on bottom edge
                let y = highlight_area.y + highlight_area.height - 1;
                for x in highlight_area.x..highlight_area.x + highlight_area.width {
                    if let Some(cell) = buf.cell_mut((x, y)) {
                        cell.set_symbol("▄");
                        cell.set_fg(drop_zone_border);
                    }
                }
            }
            TabDropZone::SplitCenter(_) | TabDropZone::TabBar(_, _) => {
                // For center and tab bar, the filled background is sufficient
            }
        }
    }

    /// Recompute the view_line_mappings layout without drawing.
    /// Used during macro replay so that visual-line movements (MoveLineEnd,
    /// MoveUp, MoveDown on wrapped lines) see correct, up-to-date layout
    /// information between each replayed action.
    pub fn recompute_layout(&mut self, width: u16, height: u16) {
        let size = ratatui::layout::Rect::new(0, 0, width, height);

        // Replicate the pre-render sync steps from render()
        let active_split = self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.splits())
            .map(|(mgr, _)| mgr)
            .expect("active window must have a populated split layout")
            .active_split();
        self.active_window_mut()
            .pre_sync_ensure_visible(active_split);
        self.active_window_mut().sync_scroll_groups();

        // Replicate the layout computation that produces editor_content_area.
        // Same constraints as render(): [menu_bar, main_content, status_bar, search_options, prompt_line]
        let constraints = vec![
            Constraint::Length(if self.active_window_mut().menu_bar_visible {
                1
            } else {
                0
            }),
            Constraint::Min(0),
            Constraint::Length(if self.active_window_mut().status_bar_visible {
                1
            } else {
                0
            }), // status bar
            Constraint::Length(0), // search options (doesn't matter for layout)
            Constraint::Length(if self.active_window_mut().prompt_line_visible {
                1
            } else {
                0
            }), // prompt line
        ];
        let main_chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints(constraints)
            .split(size);
        let main_content_area = main_chunks[1];

        // Compute editor_content_area (with file explorer split if visible)
        let file_explorer_should_show = self.file_explorer_visible()
            && (self.file_explorer().is_some()
                || self.active_window().file_explorer_sync_in_progress);
        let editor_content_area = if file_explorer_should_show {
            let explorer_cols = self
                .active_window()
                .file_explorer_width
                .to_cols(main_content_area.width);
            let horizontal_chunks = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
                .split(main_content_area);
            horizontal_chunks[1]
        } else {
            main_content_area
        };

        // Compute layout for all visible splits and update cached view_line_mappings.
        // Take one &mut borrow on the active window's splits; destructure into
        // (&SplitManager, &mut HashMap<...>) so both arguments come from the
        // same `&mut self.windows` borrow.
        let active_window_id = self.active_window;
        let __win_l = self
            .windows
            .get_mut(&active_window_id)
            .expect("active window must exist");
        let tab_bar_visible = __win_l.tab_bar_visible;
        let theme = self.theme.read().unwrap().clone();
        let view_line_mappings = __win_l
            .buffers
            .with_all_mut(|buffers, mgr, vs_map| {
                SplitRenderer::compute_content_layout(
                    editor_content_area,
                    &*mgr,
                    buffers,
                    vs_map,
                    &theme,
                    false, // lsp_waiting — not relevant for layout
                    self.config.editor.estimated_line_length,
                    self.config.editor.highlight_context_bytes,
                    self.config.editor.relative_line_numbers,
                    self.config.editor.use_terminal_bg,
                    self.session_mode || !self.software_cursor_only,
                    self.software_cursor_only,
                    tab_bar_visible,
                    self.config.editor.show_vertical_scrollbar,
                    self.config.editor.show_horizontal_scrollbar,
                    self.config.editor.diagnostics_inline_text,
                    self.config.editor.show_tilde,
                    crate::view::bracket_highlight_overlay::BracketHighlightSettings::from_config(
                        &self.config.editor,
                    ),
                )
            })
            .expect("active window must have a populated split layout");

        self.active_layout_mut().view_line_mappings = view_line_mappings;
    }

    /// Clear the search history
    /// Used primarily for testing to ensure test isolation
    pub fn clear_search_history(&mut self) {
        if let Some(history) = self.active_window_mut().prompt_histories.get_mut("search") {
            history.clear();
        }
    }

    /// Emit an OSC 2 escape sequence to set the host terminal's window/tab
    /// title based on the active buffer's display name and the project name
    /// (the working directory's last path component). Deduplicated against
    /// the last title we wrote so we don't spam stdout every frame.
    ///
    /// Gated by `editor.set_window_title` (default on). Terminals that
    /// don't implement OSC 2 silently drop the sequence.
    fn update_terminal_title(&mut self, display_name: &str) {
        if !self.config.editor.set_window_title {
            return;
        }
        let project_name = self.working_dir().file_name().and_then(|s| s.to_str());
        let new_title =
            crate::services::terminal_title::build_window_title(display_name, project_name);
        if self.last_window_title.as_deref() == Some(new_title.as_str()) {
            return;
        }
        crate::services::terminal_title::write_terminal_title(&new_title);
        self.last_window_title = Some(new_title);
    }

    /// Save all prompt histories to disk
    /// Called on shutdown to persist history across sessions
    pub fn save_histories(&self) {
        // Ensure data directory exists
        if let Err(e) = self
            .authority()
            .filesystem
            .create_dir_all(&self.dir_context.data_dir)
        {
            tracing::warn!("Failed to create data directory: {}", e);
            return;
        }

        // Save all prompt histories
        for (key, history) in &self.active_window().prompt_histories {
            let path = self.dir_context.prompt_history_path(key);
            if let Err(e) = history.save_to_file(&path) {
                tracing::warn!("Failed to save {} history: {}", key, e);
            } else {
                tracing::debug!("Saved {} history to {:?}", key, path);
            }
        }
    }

    /// Resolve a plugin-supplied [`OverlayOptions`] to a ratatui
    /// [`Style`] against the active theme. RGB colours pass through;
    /// theme keys (e.g. `"ui.help_key_fg"`) are looked up via
    /// `theme.resolve_theme_key`. Mirrors the resolution
    /// `OverlayFace::from_options` + char_style.rs do for buffer
    /// overlays — pulled here so the prompt-frame renderer can build
    /// styled spans inline.
    /// Compute a centered overlay rect of `width_pct` × `height_pct`
    /// of the given area. Mirrors `PopupPosition::CenteredOverlay`
    /// math used by `render_overlay_prompt`; minimum 20×8 cells so
    /// content stays legible on tiny terminals.
    pub(super) fn centered_overlay_rect(
        area: ratatui::layout::Rect,
        width_pct: u8,
        height_pct: u8,
    ) -> ratatui::layout::Rect {
        let w_pct = width_pct.clamp(1, 100) as u32;
        let h_pct = height_pct.clamp(1, 100) as u32;
        let w = ((area.width as u32 * w_pct) / 100) as u16;
        let h = ((area.height as u32 * h_pct) / 100) as u16;
        let w = w.max(20).min(area.width);
        let h = h.max(8).min(area.height);
        ratatui::layout::Rect {
            x: area.x + (area.width.saturating_sub(w)) / 2,
            y: area.y + (area.height.saturating_sub(h)) / 2,
            width: w,
            height: h,
        }
    }

    /// Render the currently-mounted floating widget panel: dim the
    /// background outside the centered rect, draw the frame, paint
    /// the panel's rendered entries inside, and place the hardware
    /// caret at the focused TextInput. Stores the inner rect on the
    /// `FloatingWidgetState` so the click hit-test can recover the
    /// geometry on the next mouse event.
    /// Split `size` into an optional full-height left dock column and
    /// the remaining chrome area. Returns `(None, size)` unless a
    /// floating panel is currently placed as a `LeftDock`. The dock
    /// width is clamped so it can never crowd out the chrome.
    pub(super) fn compute_dock_split(
        &self,
        size: ratatui::layout::Rect,
    ) -> (Option<ratatui::layout::Rect>, ratatui::layout::Rect) {
        // The editor is the priority. Reserve at least EDITOR_MIN columns
        // for the buffer, and once the terminal is too narrow to fit a
        // worthwhile dock alongside that editor, hide the dock entirely
        // (it reappears when the terminal grows). Previously the dock kept
        // ALL but 4 columns, squishing the editor to a useless sliver on a
        // narrow terminal.
        const EDITOR_MIN: u16 = 20;
        const DOCK_MIN: u16 = 24;
        let requested = match self.dock.as_ref().map(|f| f.placement) {
            Some(super::PanelPlacement::LeftDock { width_cols }) => width_cols,
            _ => return (None, size),
        };
        // Widest the dock may be while leaving the editor its minimum.
        let max_dock = size.width.saturating_sub(EDITOR_MIN);
        if max_dock < DOCK_MIN {
            // Not enough room for a usable dock + editor — give the editor
            // the whole frame this render.
            return (None, size);
        }
        // Honor the requested (drag-set) width, but never crowd the editor
        // below EDITOR_MIN. In the shrink band the dock narrows from its
        // requested width down to DOCK_MIN before it hides.
        let width = requested.min(max_dock).max(1);
        let dock = ratatui::layout::Rect {
            x: size.x,
            y: size.y,
            width,
            height: size.height,
        };
        let chrome = ratatui::layout::Rect {
            x: size.x.saturating_add(width),
            y: size.y,
            width: size.width.saturating_sub(width),
            height: size.height,
        };
        (Some(dock), chrome)
    }

    /// Paint a scrollbar over each overflowing `List`/`Tree` of every
    /// widget panel mounted into a visible editor split (Settings,
    /// Search & Replace, the code-tour dock). Floating panels paint
    /// theirs inside [`render_floating_widget_panel`]; split-mounted
    /// panels go through the ordinary buffer pipeline, which knows
    /// nothing about widget geometry, so without this pass their
    /// overflowing lists show no scrollbar at all.
    fn render_split_widget_panel_scrollbars(&mut self, frame: &mut Frame) {
        use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};

        // Collect paint jobs first so the layout/registry borrows end
        // before the frame is written.
        let mut jobs: Vec<(ratatui::layout::Rect, ScrollbarState)> = Vec::new();
        for (split_id, buffer_id, content_rect, _, _, _) in &self.active_layout().split_areas {
            let panels = self.widget_registry.panels_for_buffer(*buffer_id);
            if panels.is_empty() {
                continue;
            }
            // The panel body is pinned to the top in practice, but honour
            // a scrolled viewport all the same (mirrors the wheel-routing
            // translation in `handle_split_widget_panel_wheel`).
            let top_byte = self
                .windows
                .get(&self.active_window)
                .and_then(|w| w.buffers.splits())
                .map(|(_, vs)| vs)
                .and_then(|vs| vs.get(split_id))
                .map(|vs| vs.viewport.top_byte())
                .unwrap_or(0);
            let (top_line, gutter) = self
                .windows
                .get(&self.active_window)
                .map(|w| &w.buffers)
                .and_then(|b| b.get(buffer_id))
                .map(|s| {
                    (
                        s.buffer.get_line_number(top_byte),
                        s.margins.left_total_width() as u16,
                    )
                })
                .unwrap_or((0, 0));
            for panel_key in panels {
                let Some(panel) = self.widget_registry.get(&panel_key) else {
                    continue;
                };
                for region in &panel.scroll_regions {
                    // Geometry regions cover every keyed list; only
                    // overflowing ones earn a scrollbar.
                    if region.total <= region.visible {
                        continue;
                    }
                    let Some(rel_row) = (region.buffer_row as usize).checked_sub(top_line) else {
                        continue;
                    };
                    let y = content_rect.y as usize + rel_row;
                    let bottom = (content_rect.y + content_rect.height) as usize;
                    if y >= bottom {
                        continue;
                    }
                    let h = (region.height_rows as usize).min(bottom - y);
                    if h == 0 {
                        continue;
                    }
                    // Scrollbar column = right edge of the list's region,
                    // clamped inside the split.
                    let sb_x = content_rect
                        .x
                        .saturating_add(gutter)
                        .saturating_add(region.col_in_row as u16)
                        .saturating_add(region.width_cols.saturating_sub(1) as u16)
                        .min(content_rect.x + content_rect.width.saturating_sub(1));
                    jobs.push((
                        ratatui::layout::Rect {
                            x: sb_x,
                            y: y as u16,
                            width: 1,
                            height: h as u16,
                        },
                        ScrollbarState::new(region.total, region.visible, region.scroll),
                    ));
                }
            }
        }
        if jobs.is_empty() {
            return;
        }
        let colors = {
            let theme = self.theme.read().unwrap();
            ScrollbarColors::from_theme(&theme)
        };
        for (rect, state) in jobs {
            render_scrollbar(frame, rect, &state, &colors);
        }
    }

    pub(super) fn render_floating_widget_panel(
        &mut self,
        frame: &mut Frame,
        area: ratatui::layout::Rect,
        slot: super::PanelSlot,
    ) {
        use ratatui::widgets::{Block, Borders, Clear};

        let (
            width_pct,
            height_pct,
            entries,
            focus_cursor,
            embeds,
            overlays,
            scroll_regions,
            placement,
            panel_focused,
            scrollbar_zone_hovered,
            scrollbar_flash_until,
            title,
            closable,
            dropdown_popup,
        ) = match self.panel(slot) {
            Some(fwp) => (
                fwp.width_pct,
                fwp.height_pct,
                fwp.entries.clone(),
                fwp.focus_cursor,
                fwp.embeds.clone(),
                fwp.overlays.clone(),
                fwp.scroll_regions.clone(),
                fwp.placement,
                fwp.focused,
                fwp.scrollbar_zone_hovered,
                fwp.scrollbar_flash_until,
                fwp.title.clone(),
                fwp.closable,
                fwp.dropdown_popup.clone(),
            ),
            None => return,
        };
        let theme = self.theme.read().unwrap().clone();
        // Compute the requested rect from width%/height%, then
        // shrink the height to fit the rendered content (Bug 7).
        // Plugins call `mount({widthPct, heightPct})` mostly because
        // they don't know how tall their content is up front; the
        // requested height should act as a *max*, not a fixed
        // canvas. Without this shrink, the new-session form's 10
        // content rows leave ~20 blank rows under "Tab next  S-Tab
        // prev  Enter submit  Esc cancel" inside a 90%-of-screen
        // panel.
        //
        // Entries include every row the spec produces — including
        // WindowEmbed reservations (each `windowEmbed({rows: N})`
        // contributes N blank entries plus an EmbedRect that paints
        // over them at draw time). So `entries.len() + 2` (top
        // border + content + bottom border) is the natural fit.
        // A left-dock panel fills its carved column (`area` is already
        // the dock rect) at full height and does NOT dim the chrome —
        // it's a persistent, non-modal companion to the editor, not a
        // modal overlay. The centered placement keeps the historical
        // fit-to-content + background-dim behaviour.
        let is_dock = matches!(placement, super::PanelPlacement::LeftDock { .. });
        let overlay_rect = match placement {
            super::PanelPlacement::LeftDock { .. } => area,
            super::PanelPlacement::Anchored { x, y } => {
                // Size to the rendered content (not a percentage): an
                // unobtrusive popup hugs its items. Width = widest entry +
                // borders; height = entry count + borders. Then clamp the
                // top-left so the whole box stays on screen.
                use crate::primitives::display_width::str_width;
                let content_w = entries
                    .iter()
                    .map(|e| str_width(&e.text) as u16)
                    .max()
                    .unwrap_or(0);
                let w = content_w.saturating_add(2).clamp(6, area.width);
                let needed_h = (entries.len() as u16).saturating_add(2);
                let h = needed_h.clamp(3, area.height);
                let max_x = area.x + area.width.saturating_sub(w);
                let max_y = area.y + area.height.saturating_sub(h);
                ratatui::layout::Rect {
                    x: x.clamp(area.x, max_x),
                    y: y.clamp(area.y, max_y),
                    width: w,
                    height: h,
                }
            }
            super::PanelPlacement::Centered => {
                let requested = Self::centered_overlay_rect(area, width_pct, height_pct);
                let needed_h = (entries.len() as u16).saturating_add(2);
                // Fit the content in BOTH directions: the requested height is
                // a hint, never a guillotine. Shorter content shrinks the box
                // (the original Bug 7 fix); content taller than the requested
                // percentage GROWS it, up to the frame. Capping at the request
                // silently amputated the tail of the spec — on a 24-row
                // terminal the dock's delete confirmation (mounted at 44%,
                // i.e. 10 rows) lost its `[ Cancel ] [ Confirm Delete ]` row
                // entirely, so the modal read as "up but not focused" while
                // the (invisible) focused button still answered Enter.
                // Clipping is only acceptable when the frame itself is too
                // small, which `area.height` already expresses.
                let effective_h = needed_h.clamp(3, area.height.max(3));
                ratatui::layout::Rect {
                    x: requested.x,
                    y: area.y + (area.height.saturating_sub(effective_h)) / 2,
                    width: requested.width,
                    height: effective_h,
                }
            }
        };

        // Native modal-frame chrome (the declarative dialog's *shell*) is a
        // centered-modal-only affordance: a title bar drawn into the top
        // border and a `[×]` close button one cell in from the top-right
        // corner. The dock draws only a right divider and the anchored popup
        // hugs its content, so neither wears this chrome. The button rect is
        // computed here — before the draw / no-draw split below — so the web
        // projection and the mouse hit-test both see it even when we compute
        // geometry without painting cells (`suppress_chrome_cells`).
        let is_centered = matches!(placement, super::PanelPlacement::Centered);
        let close_button_rect = if is_centered && closable && overlay_rect.width >= 5 {
            Some(ratatui::layout::Rect {
                x: overlay_rect.x + overlay_rect.width - 4,
                y: overlay_rect.y,
                width: 3,
                height: 1,
            })
        } else {
            None
        };

        // Web renders this panel natively from `widgets_view`; compute geometry
        // (incl. `last_inner_rect` for click routing) but paint no cells. TUI
        // passes draw=true so its rendering is unchanged.
        let draw = !self.suppress_chrome_cells;
        // Only the centered modal dims the background; the dock and the
        // anchored context-menu popup paint over the editor without it.
        if draw && matches!(placement, super::PanelPlacement::Centered) {
            crate::view::dimming::apply_dimming_excluding(frame, area, Some(overlay_rect));
        }
        if draw {
            frame.render_widget(Clear, overlay_rect);
        }
        // The dock draws ONLY a right border (a thin draggable divider) —
        // no top/left/bottom — so it reclaims those rows/cols for content
        // and reads as a panel attached to the left edge. The centered
        // modal keeps a full box.
        //
        // A focused dock lights its divider with the accent `theme.cursor`
        // (the same colour the file explorer uses for its focused border),
        // so exactly one chrome region wears the accent at a time. A blurred
        // dock falls back to the muted `popup_border_fg`, matching every
        // other unfocused panel and making "who has the keyboard" obvious.
        let dock_border_fg = if is_dock && panel_focused {
            theme.cursor
        } else {
            theme.popup_border_fg
        };
        let mut block = Block::default()
            .borders(if is_dock {
                Borders::RIGHT
            } else {
                Borders::ALL
            })
            .border_style(ratatui::style::Style::default().fg(dock_border_fg))
            .style(ratatui::style::Style::default().bg(theme.suggestion_bg));
        // Native modal-frame title bar: a centered modal with a `title`
        // renders it left-aligned into the top border (` My Dialog `),
        // styled like the frame. Ratatui reserves the border cells the
        // title overlaps, and a `closable` panel's `[×]` is painted after
        // the block so it always sits on top at the top-right corner.
        if is_centered {
            if let Some(t) = title.as_deref() {
                block = block.title(ratatui::text::Span::styled(
                    format!(" {t} "),
                    ratatui::style::Style::default()
                        .fg(theme.popup_border_fg)
                        .add_modifier(ratatui::style::Modifier::BOLD),
                ));
            }
        }
        let inner = block.inner(overlay_rect);
        if draw {
            frame.render_widget(block, overlay_rect);
            // Paint the `[×]` close button over the top border, after the
            // block so it wins the corner cells. Its screen rect was
            // recorded above for the mouse hit-test / web projection.
            if let Some(cbr) = close_button_rect {
                frame.buffer_mut().set_string(
                    cbr.x,
                    cbr.y,
                    "[×]",
                    ratatui::style::Style::default()
                        .fg(theme.popup_border_fg)
                        .bg(theme.suggestion_bg),
                );
            }
        }

        if inner.width == 0 || inner.height == 0 {
            if let Some(fwp) = self.panel_mut(slot) {
                fwp.last_inner_rect = Some(inner);
                fwp.close_button_rect = close_button_rect;
            }
            return;
        }

        // Web path: record the rect for native rendering / click routing, then
        // stop before painting any content cells.
        if !draw {
            if let Some(fwp) = self.panel_mut(slot) {
                fwp.last_inner_rect = Some(inner);
                fwp.close_button_rect = close_button_rect;
            }
            return;
        }

        let dock_sw = self.active_chrome().last_frame.width;
        let max_rows = inner.height as usize;
        for (i, entry) in entries.iter().take(max_rows).enumerate() {
            let recorder = is_dock.then(|| {
                (
                    &mut self.active_chrome_mut().cell_theme_map,
                    dock_sw,
                    "Orchestrator Dock",
                )
            });
            paint_text_property_entry(
                frame,
                entry,
                inner.x,
                inner.y + i as u16,
                inner.width,
                &theme,
                recorder,
            );
        }

        // Walk WindowEmbed widgets and paint their referenced
        // editor window into the cells they reserved. Each embed
        // rect is panel-relative; translate to screen cells via
        // `inner`. We temporarily borrow `preview_window_id` to
        // reuse the existing per-window paint path — it reads
        // that field to decide which session to draw.
        let saved_preview = self.preview_window_id;
        for emb in &embeds {
            if emb.window_id == 0 {
                continue;
            }
            let ex = inner.x.saturating_add(emb.col_in_row as u16);
            let ey = inner.y.saturating_add(emb.buffer_row as u16);
            // Clip the embed rect to the panel's inner area so a
            // partially-offscreen embed (tiny terminal) doesn't
            // paint into the frame border.
            let max_w = inner.x.saturating_add(inner.width).saturating_sub(ex);
            let max_h = inner.y.saturating_add(inner.height).saturating_sub(ey);
            let w = (emb.width_cols as u16).min(max_w);
            let h = (emb.height_rows as u16).min(max_h);
            if w == 0 || h == 0 {
                continue;
            }
            let rect = ratatui::layout::Rect {
                x: ex,
                y: ey,
                width: w,
                height: h,
            };
            self.preview_window_id = Some(fresh_core::WindowId(emb.window_id as u64));
            self.render_session_preview_into_rect(frame, rect, &theme);
        }
        self.preview_window_id = saved_preview;

        // Dock "seamless tab (missing wall)": erase the right-edge divider
        // across the active session card's rows and scoop it away with
        // rounded corners just above and below, so the active card reads as
        // merging into the editor to its right (a file-folder / browser
        // tab). Painted over the wall the block drew and the card entries —
        // but BEFORE the scrollbar below, so a visible scrollbar paints on
        // top of (rather than being erased by) the tab's border cells.
        // No-op for non-dock panels and for an empty dock.
        if is_dock {
            paint_dock_seamless_active_tab(
                frame,
                overlay_rect,
                inner,
                &entries,
                max_rows,
                dock_border_fg,
                theme.suggestion_bg,
            );
        }

        // Paint a draggable scrollbar over the rightmost column of each
        // overflowing list, reusing the canonical `render_scrollbar` /
        // `ScrollbarState` (same path as the keybinding editor &
        // settings dialog). Record each track's screen rect + state so
        // the mouse handlers can hit-test press/drag against it.
        let mut scrollbar_tracks: Vec<super::WidgetScrollbarTrack> = Vec::new();
        // The dock's list scrollbars are overlay-style: shown while the
        // pointer is over the list OR briefly after a keyboard selection
        // move (the "flash", see `scrollbar_flash_until`), and hidden
        // otherwise — even when the list holds keyboard focus. Every other
        // panel keeps its scrollbar always visible.
        //
        // Hover is read from the panel-global `scrollbar_zone_hovered` memo
        // (maintained by the mouse-move handler), NOT from a per-window
        // cursor position: the latter is stored per editor window, so paging
        // through sessions with next/prev-window would swap in each window's
        // stale cursor and flicker the bar on for some sessions and off for
        // others even though the pointer never moved.
        //
        // The flash deadline is compared on the editor's `time_source` (the
        // same clock that armed it), so the harness's logical clock drives
        // expiry in tests; `check_dock_scrollbar_flash_expiry` on the editor
        // tick repaints once it passes so the bar also vanishes on an idle
        // UI without another input event.
        let dock_overlay_scrollbar = is_dock;
        let scrollbar_flash_active =
            scrollbar_flash_until.is_some_and(|until| self.time_source().now() < until);
        let mut scrollbar_hover_zones: Vec<ratatui::layout::Rect> = Vec::new();
        {
            use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
            let colors = ScrollbarColors::from_theme(&theme);
            for region in &scroll_regions {
                // Regions are emitted for every keyed list (wheel routing
                // hit-tests them); only overflowing ones get a scrollbar.
                if region.total <= region.visible {
                    continue;
                }
                // Scrollbar column = right edge of the list's column,
                // clamped inside the panel. Height = visible rows,
                // clamped to the panel bottom.
                let mut sb_x = inner
                    .x
                    .saturating_add(region.col_in_row as u16)
                    .saturating_add((region.width_cols.saturating_sub(1)) as u16)
                    .min(inner.x + inner.width.saturating_sub(1));
                // The dock reserves an editor-side gutter between the list and
                // its divider; nudge its scrollbar one column right into that
                // gutter so it hugs the divider/edge instead of floating a
                // column inboard. Still clamped inside the panel.
                if dock_overlay_scrollbar {
                    sb_x = sb_x
                        .saturating_add(1)
                        .min(inner.x + inner.width.saturating_sub(1));
                }
                let sb_y = inner.y.saturating_add(region.buffer_row as u16);
                if sb_y >= inner.y + inner.height {
                    continue;
                }
                let max_h = inner.y + inner.height - sb_y;
                let sb_h = (region.height_rows as u16).min(max_h);
                if sb_h == 0 {
                    continue;
                }
                let sb_rect = ratatui::layout::Rect {
                    x: sb_x,
                    y: sb_y,
                    width: 1,
                    height: sb_h,
                };
                // Hover zone = the list's whole visible region; hovering it
                // anywhere reveals the bar. Recorded every draw so the
                // mouse-move handler can re-render on enter/leave.
                let zone = ratatui::layout::Rect {
                    x: inner.x,
                    y: sb_y,
                    width: inner.width,
                    height: sb_h,
                };
                scrollbar_hover_zones.push(zone);
                let show =
                    !dock_overlay_scrollbar || scrollbar_zone_hovered || scrollbar_flash_active;
                if !show {
                    // Hidden: skip painting and recording a draggable track —
                    // an invisible bar shouldn't be grabbable. (The pointer
                    // can't be on the track without being in the zone, so a
                    // visible bar is always available before a press lands.)
                    continue;
                }
                let state = ScrollbarState::new(region.total, region.visible, region.scroll);
                render_scrollbar(frame, sb_rect, &state, &colors);
                scrollbar_tracks.push(super::WidgetScrollbarTrack {
                    list_key: region.list_key.clone(),
                    rect: sb_rect,
                    total: region.total,
                    visible: region.visible,
                    scroll: region.scroll,
                });
            }
        }

        // Paint overlay rows AFTER the main entries + embeds. Each
        // overlay row sits on top of whatever's at its
        // `buffer_row` (the row it would have occupied if it
        // weren't floating). Used for dropdown completions
        // anchored to a text input — the completion list rows
        // overpaint the form's static rows beneath without
        // shifting them on every show / hide.
        //
        // Clear the row first so the underlying entry's text
        // doesn't bleed past the overlay's content width.
        // `Paragraph` only paints cells it has content for; a
        // bare `Clear` resets the row to the panel background
        // (the `Block` here just supplies the bg style — no
        // borders).
        let panel_bg = theme.popup_bg;
        let panel_bg_style = ratatui::style::Style::default().bg(panel_bg);
        let overlay_sw = self.active_chrome().last_frame.width;
        for o in &overlays {
            let row_y = inner.y.saturating_add(o.buffer_row as u16);
            if row_y >= inner.y.saturating_add(inner.height) {
                continue;
            }
            let row_rect = ratatui::layout::Rect {
                x: inner.x,
                y: row_y,
                width: inner.width,
                height: 1,
            };
            frame.render_widget(Clear, row_rect);
            frame.render_widget(Block::default().style(panel_bg_style), row_rect);
            let recorder = is_dock.then(|| {
                (
                    &mut self.active_chrome_mut().cell_theme_map,
                    overlay_sw,
                    "Orchestrator Dock",
                )
            });
            paint_text_property_entry(
                frame,
                &o.entry,
                inner.x,
                row_y,
                inner.width,
                &theme,
                recorder,
            );
        }

        // ---- Open-Dropdown floating pop-over ---------------------------
        // Painted AFTER the panel content, at the trigger's SCREEN row and
        // clamped to the whole frame (`area`) rather than the panel — so
        // the option list extends past the panel/modal border instead of
        // growing/clipping it. Geometry mirrors the
        // `PanelPlacement::Anchored` popup: hug the content, and flip above
        // the trigger when there's no room below. Each option's screen rect
        // is recorded so the mouse hit-test can route a click (which lands
        // outside the panel's inner rect) back to `dropdown_select`.
        let mut dropdown_popup_hits: Vec<super::DropdownPopupOptionHit> = Vec::new();
        let mut dropdown_popup_rect: Option<ratatui::layout::Rect> = None;
        if let Some(dp) = dropdown_popup.as_ref() {
            use crate::primitives::display_width::{byte_offset_at_visual_column, str_width};
            use ratatui::style::{Modifier, Style};
            let visible = dp
                .options
                .len()
                .min(crate::widgets::DROPDOWN_VISIBLE_OPTIONS);
            if visible > 0 {
                let max_scroll = dp.options.len().saturating_sub(visible);
                let scroll = dp.scroll.min(max_scroll);
                // Box width = widest visible option + 1 col of left padding,
                // + 2 for the borders; clamped to the frame.
                let content_w = dp
                    .options
                    .iter()
                    .skip(scroll)
                    .take(visible)
                    .map(|o| str_width(o) as u16)
                    .max()
                    .unwrap_or(0);
                let w = content_w
                    .saturating_add(1)
                    .saturating_add(2)
                    .clamp(4, area.width);
                let h = (visible as u16).saturating_add(2).clamp(3, area.height);
                // Anchor to the trigger's screen row; prefer opening below
                // (one row under the trigger), flipping above when the box
                // would run off the bottom of the frame.
                let anchor_screen_y = inner.y.saturating_add(dp.anchor_row as u16);
                let below_y = anchor_screen_y.saturating_add(1);
                let bottom = area.y + area.height;
                let y = if below_y.saturating_add(h) <= bottom {
                    below_y
                } else {
                    anchor_screen_y.saturating_sub(h)
                };
                // Anchor the box under the trigger's `[` (its display column
                // within the row) rather than at the panel's left edge, then
                // clamp so it stays inside the frame.
                let anchor_screen_x = inner.x.saturating_add(dp.anchor_col as u16);
                let x = anchor_screen_x.min(area.x + area.width.saturating_sub(w));
                let y = y.clamp(area.y, area.y + area.height.saturating_sub(h));
                let popup_rect = ratatui::layout::Rect {
                    x,
                    y,
                    width: w,
                    height: h,
                };
                frame.render_widget(Clear, popup_rect);
                let popup_block = Block::default()
                    .borders(Borders::ALL)
                    .border_style(ratatui::style::Style::default().fg(theme.popup_border_fg))
                    .style(ratatui::style::Style::default().bg(theme.suggestion_bg));
                let popup_inner = popup_block.inner(popup_rect);
                frame.render_widget(popup_block, popup_rect);
                for (row_i, idx) in (scroll..scroll + visible).enumerate() {
                    let ry = popup_inner.y + row_i as u16;
                    if ry >= popup_inner.y + popup_inner.height {
                        break;
                    }
                    let row_rect = ratatui::layout::Rect {
                        x: popup_inner.x,
                        y: ry,
                        width: popup_inner.width,
                        height: 1,
                    };
                    let selected = idx == dp.selected;
                    let row_bg = if selected {
                        theme.suggestion_selected_bg
                    } else {
                        theme.suggestion_bg
                    };
                    frame.render_widget(
                        Block::default().style(ratatui::style::Style::default().bg(row_bg)),
                        row_rect,
                    );
                    // One col of left padding, truncated to the interior.
                    let avail = popup_inner.width.saturating_sub(1) as usize;
                    let opt = dp.options.get(idx).map(|s| s.as_str()).unwrap_or("");
                    let cut = byte_offset_at_visual_column(opt, avail);
                    let text = &opt[..cut];
                    let mut style = Style::default().fg(theme.suggestion_fg).bg(row_bg);
                    if selected {
                        style = style.add_modifier(Modifier::BOLD);
                    }
                    frame
                        .buffer_mut()
                        .set_string(popup_inner.x + 1, ry, text, style);
                    dropdown_popup_hits.push(super::DropdownPopupOptionHit {
                        rect: row_rect,
                        index: idx,
                    });
                }
                dropdown_popup_rect = Some(popup_rect);
            }
        }

        if let Some(fc) = focus_cursor {
            let cx = inner.x.saturating_add(byte_to_screen_col(
                entries
                    .get(fc.buffer_row as usize)
                    .map(|e| e.text.as_str())
                    .unwrap_or(""),
                fc.byte_in_row as usize,
            ) as u16);
            let cy = inner.y.saturating_add(fc.buffer_row as u16);
            if cx < inner.x + inner.width && cy < inner.y + inner.height {
                frame.set_cursor_position((cx, cy));
            }
        } else if panel_focused {
            // No focused text input, and the panel owns the keyboard —
            // the underlying editor's `set_cursor_position` (called
            // earlier this frame) would otherwise leave a hardware
            // caret blinking inside the dimmed buffer behind the panel.
            // Park it on the panel's bottom-right corner so it hides
            // under the panel chrome. A *blurred* dock skips this: the
            // editor beside it is focused and must keep its caret.
            let cx = inner.x + inner.width.saturating_sub(1);
            let cy = inner.y + inner.height.saturating_sub(1);
            frame.set_cursor_position((cx, cy));
        }

        if let Some(fwp) = self.panel_mut(slot) {
            fwp.last_inner_rect = Some(inner);
            fwp.close_button_rect = close_button_rect;
            fwp.scrollbar_tracks = scrollbar_tracks;
            fwp.scrollbar_hover_zones = scrollbar_hover_zones;
            fwp.dropdown_popup_hits = dropdown_popup_hits;
            fwp.dropdown_popup_rect = dropdown_popup_rect;
        }
    }

    fn resolve_overlay_style(
        opts: &fresh_core::api::OverlayOptions,
        theme: &crate::view::theme::Theme,
    ) -> ratatui::style::Style {
        use crate::view::theme::named_color_from_str;
        use fresh_core::api::OverlayColorSpec;
        use ratatui::style::{Color, Modifier, Style};

        let resolve = |spec: &OverlayColorSpec| -> Option<Color> {
            match spec {
                OverlayColorSpec::Rgb(r, g, b) => Some(Color::Rgb(*r, *g, *b)),
                OverlayColorSpec::ThemeKey(k) => {
                    named_color_from_str(k).or_else(|| theme.resolve_theme_key(k))
                }
            }
        };

        let mut style = Style::default();
        if let Some(ref fg) = opts.fg {
            if let Some(c) = resolve(fg) {
                style = style.fg(c);
            }
        }
        if let Some(ref bg) = opts.bg {
            if let Some(c) = resolve(bg) {
                style = style.bg(c);
            }
        }
        let mut m = Modifier::empty();
        if opts.bold {
            m |= Modifier::BOLD;
        }
        if opts.italic {
            m |= Modifier::ITALIC;
        }
        if opts.underline {
            m |= Modifier::UNDERLINED;
        }
        if opts.strikethrough {
            m |= Modifier::CROSSED_OUT;
        }
        if opts.reversed {
            m |= Modifier::REVERSED;
        }
        if !m.is_empty() {
            style = style.add_modifier(m);
        }
        style
    }
}

/// Paint the dock's "seamless tab (missing wall)" treatment for the
/// active session card.
///
/// The dock normally draws a full-height right-edge divider (the
/// "wall") separating its column from the editor. For the active
/// session — the one mirrored in the main view — we erase the wall
/// across the card's rows and scoop the divider away with rounded
/// corners just above and below it, so the card visually merges into
/// the editor to its right:
///
/// ```text
///                    │   <- wall (untouched) above
/// ╭──────────────────╯   <- top edge scoops up into the wall
/// │  session (active)     <- right side open: flows into the editor
/// ╰──────────────────╮   <- bottom edge scoops down into the wall
///                    │   <- wall resumes below
/// ```
///
/// The active card is located by the heavy box glyphs that
/// `mark_list_card_selected` stamps onto exactly one card's rows; its first
/// and last such rows bound the band. No-ops when no card is selected
/// (e.g. an empty dock) so the plain wall stands.
fn paint_dock_seamless_active_tab(
    frame: &mut ratatui::Frame,
    overlay_rect: ratatui::layout::Rect,
    inner: ratatui::layout::Rect,
    entries: &[fresh_core::text_property::TextPropertyEntry],
    max_rows: usize,
    border_fg: ratatui::style::Color,
    bg: ratatui::style::Color,
) {
    // Rows of the (single) selected card carry the heavy box glyphs that
    // `mark_list_card_selected` stamps — the corners on its border rows and the
    // `┃` bars on its content rows. No other dock row uses them.
    fn is_active_card_row(s: &str) -> bool {
        s.chars().any(|c| matches!(c, '┏' | '┓' | '┗' | '┛' | '┃'))
    }
    fn set_cell(
        frame: &mut ratatui::Frame,
        x: u16,
        y: u16,
        sym: &str,
        fg: ratatui::style::Color,
        bg: ratatui::style::Color,
    ) {
        if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
            cell.set_symbol(sym);
            cell.set_fg(fg);
            cell.set_bg(bg);
        }
    }

    // Locate the active card's contiguous row band.
    let mut top: Option<usize> = None;
    let mut bot = 0usize;
    for (i, e) in entries.iter().take(max_rows).enumerate() {
        if is_active_card_row(&e.text) {
            top.get_or_insert(i);
            bot = i;
        }
    }
    let Some(top) = top else { return };
    // Need a top border, at least one content row, and a bottom border.
    if bot < top + 2 {
        return;
    }
    // Row-level tree scrolling can clip the active card at a viewport
    // edge; only paint the tab when the card's actual border rows are
    // both on screen — scooping over a clipped content row would draw
    // a border through the card's text.
    let has_corner = |row: usize, corner: char| {
        entries
            .get(row)
            .map(|e| e.text.contains(corner))
            .unwrap_or(false)
    };
    if !has_corner(top, '┏') || !has_corner(bot, '┗') {
        return;
    }

    // `inner.x` is the dock's left edge; the wall sits one column past the
    // inner area (the block's `Borders::RIGHT`).
    let wall_x = overlay_rect.x + overlay_rect.width.saturating_sub(1);
    let left_x = inner.x;
    if wall_x <= left_x + 1 {
        return;
    }
    let top_y = inner.y + top as u16;
    let bot_y = inner.y + bot as u16;

    // Top edge of the tab: ╭───…───╯  (╯ scoops up into the wall above).
    set_cell(frame, left_x, top_y, "╭", border_fg, bg);
    for x in (left_x + 1)..wall_x {
        set_cell(frame, x, top_y, "─", border_fg, bg);
    }
    set_cell(frame, wall_x, top_y, "╯", border_fg, bg);

    // Bottom edge: ╰───…───╮  (╮ scoops down into the wall below).
    set_cell(frame, left_x, bot_y, "╰", border_fg, bg);
    for x in (left_x + 1)..wall_x {
        set_cell(frame, x, bot_y, "─", border_fg, bg);
    }
    set_cell(frame, wall_x, bot_y, "╮", border_fg, bg);

    // Content rows: keep the left border, open the right — erase the card's
    // own right border, the gutter, and the wall — so the row flows into the
    // editor with no divider.
    for r in (top + 1)..bot {
        let y = inner.y + r as u16;
        set_cell(frame, left_x, y, "│", border_fg, bg);
        for x in wall_x.saturating_sub(2)..=wall_x {
            set_cell(frame, x, y, " ", border_fg, bg);
        }
    }
}

/// Paint a single rendered widget entry into the frame buffer at
/// `(x, y)` over `width` cells. Resolves the entry's segments / inline
/// overlays to styled spans using the panel's theme; trailing columns
/// are filled with spaces in the panel's bg so the row reads as one
/// solid line.
pub(crate) fn paint_text_property_entry(
    frame: &mut ratatui::Frame,
    entry: &fresh_core::text_property::TextPropertyEntry,
    x: u16,
    y: u16,
    width: u16,
    theme: &crate::view::theme::Theme,
    // When `Some`, record per-cell theme-key provenance into the
    // `cell_theme_map` (indexed by `screen_width`) under `region`, as each
    // span is laid out. Used by the orchestrator dock so Ctrl+Right-Click
    // resolves the actual key the plugin's text properties carry instead of
    // an empty cell. `None` for the completion / prompt-toolbar callers,
    // whose surfaces aren't theme-inspectable.
    mut recorder: Option<(
        &mut Vec<crate::app::types::CellThemeInfo>,
        u16,
        &'static str,
    )>,
) {
    use fresh_core::api::OverlayColorSpec;
    use ratatui::style::Style;
    use ratatui::text::{Line, Span};
    use ratatui::widgets::Paragraph;
    use std::borrow::Cow;

    let mut normalized = entry.clone();
    normalized.normalize_widths();
    let mut text = normalized.text.clone();
    while text.ends_with('\n') {
        text.pop();
    }

    // A ThemeKey overlay carries the key string we want to record; an Rgb
    // overlay is an explicit colour with no key. Named colours (no `.`) are
    // also keyless so "Open in Theme Editor" never targets a non-key.
    let key_of = |spec: &OverlayColorSpec| -> Option<Cow<'static, str>> {
        match spec {
            OverlayColorSpec::ThemeKey(k) if k.contains('.') => Some(Cow::Owned(k.clone())),
            _ => None,
        }
    };
    // Row-level base keys: the panel surface keys unless the row's own
    // style overrides fg/bg. Mirrors the `base_style` colour resolution
    // below, but tracks the key instead of the resolved colour.
    let (mut base_fg_key, mut base_bg_key) = (
        Some(Cow::Borrowed("ui.suggestion_fg")),
        Some(Cow::Borrowed("ui.suggestion_bg")),
    );
    if let Some(opts) = normalized.style.as_ref() {
        if let Some(fg) = opts.fg.as_ref() {
            base_fg_key = key_of(fg);
        }
        if let Some(bg) = opts.bg.as_ref() {
            base_bg_key = key_of(bg);
        }
    }

    let base_bg = theme.suggestion_bg;
    let base_style = if let Some(opts) = normalized.style.as_ref() {
        // Resolve the entry's row-level style, then fill in the
        // suggestion_bg only when the style didn't supply one
        // of its own. Without this guard, calling `.bg(base_bg)`
        // unconditionally would wipe out a row-level
        // `popup_selection_bg` (the highlight on the completion
        // popup's selected candidate) — `Style::bg` is a
        // replacement, not a merge.
        let mut resolved = Editor::resolve_overlay_style(opts, theme);
        // Fill in the suggestion surface's fg/bg when the style didn't
        // supply its own — `suggestion_fg` is the foreground partner for
        // `suggestion_bg`. Without an fg default, unstyled toolbar text
        // (toggle labels, "save matches") fell through to the terminal's
        // default foreground, which is unreadable on light themes.
        if resolved.fg.is_none() {
            resolved = resolved.fg(theme.suggestion_fg);
        }
        if resolved.bg.is_none() {
            resolved.bg(base_bg)
        } else {
            resolved
        }
    } else {
        Style::default().fg(theme.suggestion_fg).bg(base_bg)
    };

    // Split the line at inline-overlay byte boundaries so each
    // resulting span carries one consistent style. The overlays are
    // produced in declaration order by the widget renderer; later
    // overlays override earlier ones for any cells they cover.
    // Snap every boundary to a grapheme-cluster boundary. Overlay
    // offsets can land mid-codepoint after a row is truncated with a
    // multi-byte `…` (the overlay end isn't re-clamped to the new
    // text), and slicing `text[a..b]` on such an index panics. Valid
    // boundaries are kept as-is; an interior one floors to the previous
    // grapheme boundary (worst case a span edge shifts by one cluster,
    // invisible in practice).
    let snap = |i: usize| {
        let i = i.min(text.len());
        if text.is_char_boundary(i) {
            i
        } else {
            crate::primitives::grapheme::prev_grapheme_boundary(&text, i)
        }
    };
    let boundaries: std::collections::BTreeSet<usize> = std::iter::once(0)
        .chain(std::iter::once(text.len()))
        .chain(
            normalized
                .inline_overlays
                .iter()
                .flat_map(|o| [snap(o.start), snap(o.end)]),
        )
        .collect();
    let bounds: Vec<usize> = boundaries.into_iter().collect();

    let mut spans: Vec<Span<'_>> = Vec::new();
    // Screen column of the next span's first cell, advanced by each span's
    // display width so per-cell recording lands on the right columns
    // (wide glyphs included).
    let mut col_cursor = x;
    for win in bounds.windows(2) {
        let (a, b) = (win[0], win[1]);
        if a >= b {
            continue;
        }
        let slice = text[a..b].to_string();
        // Merge (don't replace) overlapping overlays so a later
        // overlay can override individual properties (bg, fg,
        // italic, …) without wiping the earlier overlay's other
        // properties. The text-input renderer relies on this:
        // the placeholder overlay sets fg + italic, then the
        // focused overlay sets bg only — without per-property
        // merge the focused-bg overlay would also clear the
        // placeholder's italic-dim styling, making placeholder
        // text indistinguishable from a typed value under focus.
        let mut style = base_style;
        // Track this span's effective theme keys alongside the colour,
        // applying the same overlay precedence (last writer wins).
        let mut fg_key = base_fg_key.clone();
        let mut bg_key = base_bg_key.clone();
        for o in &normalized.inline_overlays {
            let os = o.start.min(text.len());
            let oe = o.end.min(text.len());
            if a >= os && b <= oe && oe > os {
                let resolved = Editor::resolve_overlay_style(&o.style, theme);
                if let Some(fg) = resolved.fg {
                    style = style.fg(fg);
                }
                if let Some(bg) = resolved.bg {
                    style = style.bg(bg);
                }
                if let Some(fg) = o.style.fg.as_ref() {
                    fg_key = key_of(fg);
                }
                if let Some(bg) = o.style.bg.as_ref() {
                    bg_key = key_of(bg);
                }
                // Ratatui `Style` carries add/sub modifier sets;
                // OR the additions in so subsequent overlays can
                // add italic / bold / etc. on top of the prior
                // overlay's modifiers.
                style = style.add_modifier(resolved.add_modifier);
                style = style.remove_modifier(resolved.sub_modifier);
            }
        }
        // Ensure a bg is set: ratatui will paint the slot with
        // the terminal's default bg otherwise, which doesn't
        // match the surrounding panel chrome.
        if style.bg.is_none() {
            style = style.bg(base_bg);
        }
        // Record this span's cells as they're laid out (same column walk
        // the Paragraph will use), before moving the slice into the Span.
        let span_w = crate::primitives::display_width::str_width(&slice) as u16;
        if let Some((map, sw, region)) = recorder.as_mut() {
            record_entry_span_cells(
                map, *sw, region, y, col_cursor, span_w, x, width, &fg_key, &bg_key,
            );
        }
        col_cursor = col_cursor.saturating_add(span_w);
        spans.push(Span::styled(slice, style));
    }
    // Pad the row's trailing cells with the surface keys so right-clicking
    // the blank tail of a dock row still resolves the panel surface rather
    // than an empty cell.
    if let Some((map, sw, region)) = recorder.as_mut() {
        let row_end = x.saturating_add(width);
        if col_cursor < row_end {
            record_entry_span_cells(
                map,
                *sw,
                region,
                y,
                col_cursor,
                row_end - col_cursor,
                x,
                width,
                &base_fg_key,
                &base_bg_key,
            );
        }
    }

    let line = Line::from(spans);
    let rect = ratatui::layout::Rect {
        x,
        y,
        width,
        height: 1,
    };
    frame.render_widget(Paragraph::new(line).style(base_style), rect);
}

/// Record `[start_col, start_col+span_w)` of screen row `row` into the
/// per-cell theme map under `region`, clipped to the entry's
/// `[clip_x, clip_x+clip_width)` band. Called as each span of a widget
/// entry is laid out so the theme inspector resolves the same keys that
/// were painted.
#[allow(clippy::too_many_arguments)]
fn record_entry_span_cells(
    map: &mut [crate::app::types::CellThemeInfo],
    sw: u16,
    region: &'static str,
    row: u16,
    start_col: u16,
    span_w: u16,
    clip_x: u16,
    clip_width: u16,
    fg_key: &Option<std::borrow::Cow<'static, str>>,
    bg_key: &Option<std::borrow::Cow<'static, str>>,
) {
    if sw == 0 || span_w == 0 {
        return;
    }
    let row_end = clip_x.saturating_add(clip_width);
    let end_col = start_col.saturating_add(span_w).min(row_end);
    let sw_us = sw as usize;
    for col in start_col..end_col {
        let idx = row as usize * sw_us + col as usize;
        if let Some(cell) = map.get_mut(idx) {
            *cell = crate::app::types::CellThemeInfo {
                fg_key: fg_key.clone(),
                bg_key: bg_key.clone(),
                region: std::borrow::Cow::Borrowed(region),
                syntax_category: None,
            };
        }
    }
}

/// Translate a UTF-8 byte offset within a rendered line into a
/// display-column offset, walking codepoints with their Unicode
/// width. Used to place the hardware caret on the focused
/// TextInput's byte position.
fn byte_to_screen_col(text: &str, target_byte: usize) -> usize {
    use unicode_width::UnicodeWidthChar;
    let mut byte = 0;
    let mut col = 0usize;
    for ch in text.chars() {
        if byte >= target_byte {
            break;
        }
        col += UnicodeWidthChar::width(ch).unwrap_or(0);
        byte += ch.len_utf8();
    }
    col
}
