Skip to main content

fresh/app/
render.rs

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