Skip to main content

fresh/app/
render.rs

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