Skip to main content

fresh/app/
render.rs

1use super::lsp_status::compose_lsp_status;
2use super::*;
3
4impl Editor {
5    /// Render the editor to the terminal
6    pub fn render(&mut self, frame: &mut Frame) {
7        let _span = tracing::info_span!("render").entered();
8        let size = frame.area();
9
10        // Let active animations snapshot the previous frame's buffer
11        // from the runner's own cache. We can't read the live
12        // `frame.buffer_mut()` — ratatui resets it before each draw —
13        // so the runner keeps a post-apply clone from the last frame.
14        self.animations.capture_before_all();
15
16        // Save frame dimensions for recompute_layout (used by macro replay)
17        self.cached_layout.last_frame_width = size.width;
18        self.cached_layout.last_frame_height = size.height;
19
20        // Reset per-cell theme key map for this frame
21        self.cached_layout.reset_cell_theme_map();
22
23        // Attach any queued LSP auto-start prompt to the currently
24        // active buffer. Done here (rather than at file-open) so the
25        // popup follows the user's focus through a session restore
26        // that opens several files of the same language in
27        // succession. No-op when nothing is queued.
28        self.drain_pending_lsp_prompt_for_active_buffer();
29
30        // For scroll sync groups, we need to update the active split's viewport position BEFORE
31        // calling sync_scroll_groups, so that the sync reads the correct position.
32        // Otherwise, cursor movements like 'G' (go to end) won't sync properly because
33        // viewport.top_byte hasn't been updated yet.
34        let active_split = self.split_manager.active_split();
35        {
36            let _span = tracing::info_span!("pre_sync_ensure_visible").entered();
37            self.pre_sync_ensure_visible(active_split);
38        }
39
40        // Synchronize scroll sync groups (anchor-based scroll for side-by-side diffs)
41        // This sets viewport positions based on the authoritative scroll_line in each group
42        {
43            let _span = tracing::info_span!("sync_scroll_groups").entered();
44            self.sync_scroll_groups();
45        }
46
47        // NOTE: Viewport sync with cursor is handled by split_rendering.rs which knows the
48        // correct content area dimensions. Don't sync here with incorrect EditorState viewport size.
49
50        // Prepare all buffers for rendering (pre-load viewport data for lazy loading)
51        // Each split may have a different viewport position on the same buffer
52        let mut semantic_ranges: std::collections::HashMap<BufferId, (usize, usize)> =
53            std::collections::HashMap::new();
54        {
55            let _span = tracing::info_span!("compute_semantic_ranges").entered();
56            for (split_id, view_state) in &self.split_view_states {
57                if let Some(buffer_id) = self.split_manager.get_buffer_id((*split_id).into()) {
58                    if let Some(state) = self.buffers.get(&buffer_id) {
59                        let start_line = state.buffer.get_line_number(view_state.viewport.top_byte);
60                        let visible_lines =
61                            view_state.viewport.visible_line_count().saturating_sub(1);
62                        let end_line = start_line.saturating_add(visible_lines);
63                        semantic_ranges
64                            .entry(buffer_id)
65                            .and_modify(|(min_start, max_end)| {
66                                *min_start = (*min_start).min(start_line);
67                                *max_end = (*max_end).max(end_line);
68                            })
69                            .or_insert((start_line, end_line));
70                    }
71                }
72            }
73        }
74        for (buffer_id, (start_line, end_line)) in semantic_ranges {
75            self.maybe_request_semantic_tokens_range(buffer_id, start_line, end_line);
76            self.maybe_request_semantic_tokens_full_debounced(buffer_id);
77            self.maybe_request_folding_ranges_debounced(buffer_id);
78        }
79
80        {
81            let _span = tracing::info_span!("prepare_for_render").entered();
82            for (split_id, view_state) in &self.split_view_states {
83                if let Some(buffer_id) = self.split_manager.get_buffer_id((*split_id).into()) {
84                    if let Some(state) = self.buffers.get_mut(&buffer_id) {
85                        let top_byte = view_state.viewport.top_byte;
86                        let height = view_state.viewport.height;
87                        if let Err(e) = state.prepare_for_render(top_byte, height) {
88                            tracing::error!("Failed to prepare buffer for render: {}", e);
89                            // Continue with partial rendering
90                        }
91                    }
92                }
93            }
94        }
95
96        // Refresh search highlights only during incremental search (when prompt is active)
97        // After search is confirmed, overlays exist for ALL matches and shouldn't be overwritten
98        let is_search_prompt_active = self.prompt.as_ref().is_some_and(|p| {
99            matches!(
100                p.prompt_type,
101                PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch
102            )
103        });
104        if is_search_prompt_active {
105            if let Some(ref search_state) = self.search_state {
106                let query = search_state.query.clone();
107                self.update_search_highlights(&query);
108            }
109        }
110
111        // Determine if we need to show search options bar
112        let show_search_options = self.prompt.as_ref().is_some_and(|p| {
113            matches!(
114                p.prompt_type,
115                PromptType::Search
116                    | PromptType::ReplaceSearch
117                    | PromptType::Replace { .. }
118                    | PromptType::QueryReplaceSearch
119                    | PromptType::QueryReplace { .. }
120            )
121        });
122
123        // Hide status bar when suggestions popup or file browser popup is shown
124        let has_suggestions = self
125            .prompt
126            .as_ref()
127            .is_some_and(|p| !p.suggestions.is_empty());
128        let has_file_browser = self.prompt.as_ref().is_some_and(|p| {
129            matches!(
130                p.prompt_type,
131                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
132            )
133        }) && self.file_open_state.is_some();
134
135        // Build main vertical layout: [menu_bar, main_content, status_bar, search_options, prompt_line]
136        // Status bar is hidden when suggestions popup is shown
137        // Search options bar is shown when in search prompt
138        let constraints = vec![
139            Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }), // Menu bar
140            Constraint::Min(0),                                            // Main content area
141            Constraint::Length(
142                if !self.status_bar_visible || has_suggestions || has_file_browser {
143                    0
144                } else {
145                    1
146                },
147            ), // Status bar (hidden when toggled off or with popups)
148            Constraint::Length(if show_search_options { 1 } else { 0 }),   // Search options bar
149            Constraint::Length(if self.prompt_line_visible || self.prompt.is_some() {
150                1
151            } else {
152                0
153            }), // Prompt line (auto-hidden when no prompt active)
154        ];
155
156        let main_chunks = Layout::default()
157            .direction(Direction::Vertical)
158            .constraints(constraints)
159            .split(size);
160
161        let menu_bar_area = main_chunks[0];
162        let main_content_area = main_chunks[1];
163        let status_bar_idx = 2;
164        let search_options_idx = 3;
165        let prompt_line_idx = 4;
166
167        // Split main content area based on file explorer visibility
168        // Also keep the layout split if a sync is in progress (to avoid flicker)
169        let editor_content_area;
170        let file_explorer_should_show = self.file_explorer_visible
171            && (self.file_explorer.is_some() || self.file_explorer_sync_in_progress);
172
173        if file_explorer_should_show {
174            // Split horizontally: [file_explorer | editor]
175            tracing::trace!(
176                "render: file explorer layout active (present={}, sync_in_progress={})",
177                self.file_explorer.is_some(),
178                self.file_explorer_sync_in_progress
179            );
180            let explorer_cols = self.file_explorer_width.to_cols(main_content_area.width);
181            let horizontal_chunks = Layout::default()
182                .direction(Direction::Horizontal)
183                .constraints([
184                    Constraint::Length(explorer_cols), // File explorer
185                    Constraint::Min(0),                // Editor area (remainder)
186                ])
187                .split(main_content_area);
188
189            self.cached_layout.file_explorer_area = Some(horizontal_chunks[0]);
190            editor_content_area = horizontal_chunks[1];
191
192            // Get connection string before mutable borrow of file_explorer.
193            let remote_connection = self.connection_display_string();
194
195            // Render file explorer (only if we have it - during sync we just keep the area reserved)
196            if let Some(ref mut explorer) = self.file_explorer {
197                let is_focused = self.key_context == KeyContext::FileExplorer;
198
199                // Build set of files with unsaved changes
200                let mut files_with_unsaved_changes = std::collections::HashSet::new();
201                for (buffer_id, state) in &self.buffers {
202                    if state.buffer.is_modified() {
203                        if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
204                            if let Some(file_path) = metadata.file_path() {
205                                files_with_unsaved_changes.insert(file_path.clone());
206                            }
207                        }
208                    }
209                }
210
211                let close_button_hovered = matches!(
212                    &self.mouse_state.hover_target,
213                    Some(HoverTarget::FileExplorerCloseButton)
214                );
215                let keybindings = self.keybindings.read().unwrap();
216                let empty: Vec<std::path::PathBuf> = Vec::new();
217                let cut_paths = self
218                    .file_explorer_clipboard
219                    .as_ref()
220                    .filter(|cb| cb.is_cut)
221                    .map(|cb| cb.paths.as_slice())
222                    .unwrap_or(empty.as_slice());
223                FileExplorerRenderer::render(
224                    explorer,
225                    frame,
226                    horizontal_chunks[0],
227                    is_focused,
228                    &files_with_unsaved_changes,
229                    &self.file_explorer_decoration_cache,
230                    &keybindings,
231                    self.key_context.clone(),
232                    &self.theme,
233                    close_button_hovered,
234                    remote_connection.as_deref(),
235                    cut_paths,
236                );
237            }
238            // Note: if file_explorer is None but sync_in_progress is true,
239            // we just leave the area blank (or could render a placeholder)
240        } else {
241            // No file explorer: use entire main content area for editor
242            self.cached_layout.file_explorer_area = None;
243            editor_content_area = main_content_area;
244        }
245
246        // Note: Tabs are now rendered within each split by SplitRenderer
247
248        // Trigger lines_changed hooks for newly visible lines in all visible buffers
249        // This allows plugins to add overlays before rendering
250        // Only lines that haven't been seen before are sent (batched for efficiency)
251        // Use non-blocking hooks to avoid deadlock when actions are awaiting
252        if self.plugin_manager.is_active() {
253            let hooks_start = std::time::Instant::now();
254            // Get visible buffers and their areas
255            let visible_buffers = self.split_manager.get_visible_buffers(editor_content_area);
256
257            let mut total_new_lines = 0usize;
258            for (split_id, buffer_id, split_area) in visible_buffers {
259                // Get viewport from SplitViewState (the authoritative source)
260                let viewport_top_byte = self
261                    .split_view_states
262                    .get(&split_id)
263                    .map(|vs| vs.viewport.top_byte)
264                    .unwrap_or(0);
265
266                if let Some(state) = self.buffers.get_mut(&buffer_id) {
267                    // Fire render_start hook once per buffer
268                    self.plugin_manager.run_hook(
269                        "render_start",
270                        crate::services::plugins::hooks::HookArgs::RenderStart { buffer_id },
271                    );
272
273                    // Fire view_transform_request hook with base tokens
274                    // This allows plugins to transform the view (e.g., soft breaks for markdown)
275                    let visible_count = split_area.height as usize;
276                    let is_binary = state.buffer.is_binary();
277                    let line_ending = state.buffer.line_ending();
278                    let base_tokens =
279                        crate::view::ui::split_rendering::SplitRenderer::build_base_tokens_for_hook(
280                            &mut state.buffer,
281                            viewport_top_byte,
282                            self.config.editor.estimated_line_length,
283                            visible_count,
284                            is_binary,
285                            line_ending,
286                        );
287                    let viewport_start = viewport_top_byte;
288                    let viewport_end = base_tokens
289                        .last()
290                        .and_then(|t| t.source_offset)
291                        .unwrap_or(viewport_start);
292                    let cursor_positions: Vec<usize> = self
293                        .split_view_states
294                        .get(&split_id)
295                        .map(|vs| vs.cursors.iter().map(|(_, c)| c.position).collect())
296                        .unwrap_or_default();
297                    self.plugin_manager.run_hook(
298                        "view_transform_request",
299                        crate::services::plugins::hooks::HookArgs::ViewTransformRequest {
300                            buffer_id,
301                            split_id: split_id.into(),
302                            viewport_start,
303                            viewport_end,
304                            tokens: base_tokens,
305                            cursor_positions,
306                        },
307                    );
308
309                    // We just sent fresh base tokens to the plugin, so any
310                    // future SubmitViewTransform from this request will be valid.
311                    // Clear the stale flag so the response will be accepted.
312                    if let Some(vs) = self.split_view_states.get_mut(&split_id) {
313                        vs.view_transform_stale = false;
314                    }
315
316                    // Use the split area height as visible line count
317                    let visible_count = split_area.height as usize;
318                    let top_byte = viewport_top_byte;
319
320                    // Get or create the seen byte ranges set for this buffer
321                    let seen_byte_ranges = self.seen_byte_ranges.entry(buffer_id).or_default();
322
323                    // Collect only NEW lines (not seen before based on byte range)
324                    let mut new_lines: Vec<crate::services::plugins::hooks::LineInfo> = Vec::new();
325                    let mut line_number = state.buffer.get_line_number(top_byte);
326                    let mut iter = state
327                        .buffer
328                        .line_iterator(top_byte, self.config.editor.estimated_line_length);
329
330                    for _ in 0..visible_count {
331                        if let Some((line_start, line_content)) = iter.next_line() {
332                            let byte_end = line_start + line_content.len();
333                            let byte_range = (line_start, byte_end);
334
335                            // Only add if this byte range hasn't been seen before
336                            if !seen_byte_ranges.contains(&byte_range) {
337                                new_lines.push(crate::services::plugins::hooks::LineInfo {
338                                    line_number,
339                                    byte_start: line_start,
340                                    byte_end,
341                                    content: line_content,
342                                });
343                                seen_byte_ranges.insert(byte_range);
344                            }
345                            line_number += 1;
346                        } else {
347                            break;
348                        }
349                    }
350
351                    // Send batched hook if there are new lines
352                    if !new_lines.is_empty() {
353                        total_new_lines += new_lines.len();
354                        self.plugin_manager.run_hook(
355                            "lines_changed",
356                            crate::services::plugins::hooks::HookArgs::LinesChanged {
357                                buffer_id,
358                                lines: new_lines,
359                            },
360                        );
361                    }
362                }
363            }
364            let hooks_elapsed = hooks_start.elapsed();
365            tracing::trace!(
366                new_lines = total_new_lines,
367                elapsed_ms = hooks_elapsed.as_millis(),
368                elapsed_us = hooks_elapsed.as_micros(),
369                "lines_changed hooks total"
370            );
371
372            // Process any plugin commands (like AddOverlay) that resulted from the hooks.
373            //
374            // This is non-blocking: we collect whatever the plugin has sent so far.
375            // The plugin thread runs in parallel, and because we proactively call
376            // handle_refresh_lines after cursor_moved (in fire_cursor_hooks), the
377            // lines_changed hook fires early in the render cycle. By the time we
378            // reach this point, the plugin has typically already processed all hooks
379            // and sent back conceal/overlay commands. On rare occasions (high CPU
380            // load), the response arrives one frame late, which is imperceptible
381            // at 60fps. The plugin's own refreshLines() call from cursor_moved
382            // ensures a follow-up render cycle picks up any missed commands.
383            let commands = self.plugin_manager.process_commands();
384            if !commands.is_empty() {
385                let cmd_names: Vec<String> =
386                    commands.iter().map(|c| c.debug_variant_name()).collect();
387                tracing::trace!(count = commands.len(), cmds = ?cmd_names, "process_commands during render");
388            }
389            for command in commands {
390                if let Err(e) = self.handle_plugin_command(command) {
391                    tracing::error!("Error handling plugin command: {}", e);
392                }
393            }
394
395            // Flush any deferred grammar rebuilds as a single batch
396            self.flush_pending_grammars();
397        }
398
399        // Render editor content (same for both layouts)
400        let lsp_waiting = !self.pending_completion_requests.is_empty()
401            || self.pending_goto_definition_request.is_some();
402
403        // Hide the hardware cursor when menu is open, file explorer is focused, terminal mode,
404        // or settings UI is open
405        // (the file explorer will set its own cursor position when focused)
406        // (terminal mode renders its own cursor via the terminal emulator)
407        // (settings UI is a modal that doesn't need the editor cursor)
408        // This also causes visual cursor indicators in the editor to be dimmed
409        let settings_visible = self.settings_state.as_ref().is_some_and(|s| s.visible);
410        let hide_cursor = self.menu_state.active_menu.is_some()
411            || self.key_context == KeyContext::FileExplorer
412            || self.terminal_mode
413            || settings_visible
414            || self.keybinding_editor.is_some();
415
416        // Convert HoverTarget to tab hover info for rendering
417        let hovered_tab = match &self.mouse_state.hover_target {
418            Some(HoverTarget::TabName(target, split_id)) => Some((*target, *split_id, false)),
419            Some(HoverTarget::TabCloseButton(target, split_id)) => Some((*target, *split_id, true)),
420            _ => None,
421        };
422
423        // Get hovered close split button
424        let hovered_close_split = match &self.mouse_state.hover_target {
425            Some(HoverTarget::CloseSplitButton(split_id)) => Some(*split_id),
426            _ => None,
427        };
428
429        // Get hovered maximize split button
430        let hovered_maximize_split = match &self.mouse_state.hover_target {
431            Some(HoverTarget::MaximizeSplitButton(split_id)) => Some(*split_id),
432            _ => None,
433        };
434
435        let is_maximized = self.split_manager.is_maximized();
436
437        // The active split's buffer renderer records where the hardware
438        // cursor *wants* to appear here; we only commit it to the frame at
439        // the very end of this draw pass, after popups have been rendered,
440        // so a popup covering the cursor cell causes the cursor to be
441        // hidden (otherwise the hardware caret would bleed through the
442        // popup).
443        let mut pending_hardware_cursor: Option<(u16, u16)> = None;
444
445        let _content_span = tracing::info_span!("render_content").entered();
446        let (
447            split_areas,
448            tab_layouts,
449            close_split_areas,
450            maximize_split_areas,
451            view_line_mappings,
452            horizontal_scrollbar_areas,
453            grouped_separator_areas,
454        ) = SplitRenderer::render_content(
455            frame,
456            editor_content_area,
457            &self.split_manager,
458            &mut self.buffers,
459            &self.buffer_metadata,
460            &mut self.event_logs,
461            &mut self.composite_buffers,
462            &mut self.composite_view_states,
463            &self.theme,
464            self.ansi_background.as_ref(),
465            self.background_fade,
466            lsp_waiting,
467            self.config.editor.large_file_threshold_bytes,
468            self.config.editor.line_wrap,
469            self.config.editor.estimated_line_length,
470            self.config.editor.highlight_context_bytes,
471            Some(&mut self.split_view_states),
472            &self.grouped_subtrees,
473            hide_cursor,
474            hovered_tab,
475            hovered_close_split,
476            hovered_maximize_split,
477            is_maximized,
478            self.config.editor.relative_line_numbers,
479            self.tab_bar_visible,
480            self.config.editor.use_terminal_bg,
481            self.session_mode || !self.software_cursor_only,
482            self.software_cursor_only,
483            self.config.editor.show_vertical_scrollbar,
484            self.config.editor.show_horizontal_scrollbar,
485            self.config.editor.diagnostics_inline_text,
486            self.config.editor.show_tilde,
487            self.config.editor.highlight_current_column,
488            &mut self.cached_layout.cell_theme_map,
489            size.width,
490            &mut pending_hardware_cursor,
491        );
492
493        drop(_content_span);
494
495        // Cursor-jump animation: compare the cursor's screen position to
496        // the prior frame and animate either when the cursor crossed split
497        // panes or moved more than two rows within the same pane. The
498        // trail crosses pane separators when the jump is across splits —
499        // that's the intended "follow the focus" cue.
500        self.maybe_start_cursor_jump_animation(pending_hardware_cursor, active_split);
501
502        // Detect viewport changes and fire hooks
503        // Compare against previous frame's viewport state (stored in self.previous_viewports)
504        // This correctly detects changes from scroll events that happen before render()
505        if self.plugin_manager.is_active() {
506            for (split_id, view_state) in &self.split_view_states {
507                let current = (
508                    view_state.viewport.top_byte,
509                    view_state.viewport.width,
510                    view_state.viewport.height,
511                );
512                // Compare against previous frame's state
513                // Skip new splits (None case) - only fire hooks for established splits
514                // This matches the original behavior where hooks only fire for splits
515                // that existed at the start of render
516                let (changed, previous) = match self.previous_viewports.get(split_id) {
517                    Some(previous) => (*previous != current, Some(*previous)),
518                    None => (false, None), // Skip new splits until they're established
519                };
520                tracing::trace!(
521                    "viewport_changed check: split={:?} current={:?} previous={:?} changed={}",
522                    split_id,
523                    current,
524                    previous,
525                    changed
526                );
527                if changed {
528                    if let Some(buffer_id) = self.split_manager.get_buffer_id((*split_id).into()) {
529                        // Compute top_line if line info is available
530                        let top_line = self.buffers.get(&buffer_id).and_then(|state| {
531                            if state.buffer.line_count().is_some() {
532                                Some(state.buffer.get_line_number(view_state.viewport.top_byte))
533                            } else {
534                                None
535                            }
536                        });
537                        tracing::debug!(
538                            "Firing viewport_changed hook: split={:?} buffer={:?} top_byte={} top_line={:?}",
539                            split_id,
540                            buffer_id,
541                            view_state.viewport.top_byte,
542                            top_line
543                        );
544                        self.plugin_manager.run_hook(
545                            "viewport_changed",
546                            crate::services::plugins::hooks::HookArgs::ViewportChanged {
547                                split_id: (*split_id).into(),
548                                buffer_id,
549                                top_byte: view_state.viewport.top_byte,
550                                top_line,
551                                width: view_state.viewport.width,
552                                height: view_state.viewport.height,
553                            },
554                        );
555                    }
556                }
557            }
558        }
559
560        // Update previous_viewports for next frame's comparison
561        self.previous_viewports.clear();
562        for (split_id, view_state) in &self.split_view_states {
563            self.previous_viewports.insert(
564                *split_id,
565                (
566                    view_state.viewport.top_byte,
567                    view_state.viewport.width,
568                    view_state.viewport.height,
569                ),
570            );
571        }
572
573        // Render terminal content on top of split content for terminal buffers
574        self.render_terminal_splits(frame, &split_areas);
575
576        self.cached_layout.split_areas = split_areas;
577        self.cached_layout.horizontal_scrollbar_areas = horizontal_scrollbar_areas;
578        self.cached_layout.tab_layouts = tab_layouts;
579        self.cached_layout.close_split_areas = close_split_areas;
580        self.cached_layout.maximize_split_areas = maximize_split_areas;
581        self.cached_layout.view_line_mappings = view_line_mappings;
582
583        // Promote any deferred virtual-buffer animations whose Rect is now
584        // known. Done here (after split_areas is recomputed, before
585        // apply_all runs at the end of render) so the first frame of the
586        // effect lands on the same paint that made the buffer visible.
587        self.drain_pending_vb_animations();
588        let mut separator_areas = self
589            .split_manager
590            .get_separators_with_ids(editor_content_area);
591        // Grouped subtrees live in a side-map outside the main split tree, so
592        // their inner separators are not visited by `get_separators_with_ids`
593        // above. The renderer collected them (using the same content rect it
594        // drew them at) — merge so clicks on those rendered columns register.
595        separator_areas.extend(grouped_separator_areas);
596        self.cached_layout.separator_areas = separator_areas;
597        self.cached_layout.editor_content_area = Some(editor_content_area);
598
599        // Render hover highlights for separators and scrollbars
600        self.render_hover_highlights(frame);
601
602        // Initialize popup/suggestion layout state (rendered after status bar below)
603        self.cached_layout.suggestions_area = None;
604        self.file_browser_layout = None;
605
606        // Clone all immutable values before the mutable borrow
607        let display_name = self
608            .buffer_metadata
609            .get(&self.active_buffer())
610            .map(|m| m.display_name.clone())
611            .unwrap_or_else(|| "[No Name]".to_string());
612
613        // Reflect the active buffer in the terminal window/tab title. Only
614        // writes when the title actually changes so we don't flood stdout
615        // with OSC sequences every frame.
616        self.update_terminal_title(&display_name);
617
618        let status_message = self.status_message.clone();
619        let plugin_status_message = self.plugin_status_message.clone();
620        let prompt = self.prompt.clone();
621        // Compute a simple buffer-aware LSP indicator.
622        // Compose the LSP status-bar segment for the active buffer. This
623        // runs every render — the editor has no precomputed LSP-status
624        // string cached anywhere else, so there is a single source of
625        // truth for what the user sees.
626        //
627        // Priority order (first non-empty wins):
628        //
629        //   1. Active `$/progress` work for this language — e.g.
630        //      "LSP (cpp): indexing (42%)". Conveys the transient
631        //      startup/indexing phase.
632        //   2. A running server — "LSP". Short because detail belongs
633        //      in LSP-specific UI, not the compact status bar pill.
634        //   3. Configured `auto_start=true` servers that haven't started
635        //      (error / crashed / pending) — "LSP off".
636        //   4. Configured `enabled && !auto_start` servers that the user
637        //      has to opt into — "LSP: off (N)".
638        //   5. Nothing.
639        //
640        // Rules 3 and 4 address heuristic eval H-1: without them, a
641        // configured-but-dormant server is indistinguishable from "no
642        // LSP at all."
643        let current_language = self
644            .buffers
645            .get(&self.active_buffer())
646            .map(|s| s.language.clone())
647            .unwrap_or_default();
648        let buffer_lsp_disabled_reason = self
649            .buffer_metadata
650            .get(&self.active_buffer())
651            .filter(|m| !m.lsp_enabled)
652            .and_then(|m| m.lsp_disabled_reason.as_deref());
653        let (lsp_status, lsp_indicator_state) = compose_lsp_status(
654            &current_language,
655            buffer_lsp_disabled_reason,
656            &self.lsp_progress,
657            &self.lsp_server_statuses,
658            &self.config.lsp,
659            &self.user_dismissed_lsp_languages,
660        );
661        let theme = self.theme.clone();
662        let keybindings_cloned = self.keybindings.read().unwrap().clone(); // Clone the keybindings
663        let chord_state_cloned = self.chord_state.clone(); // Clone the chord state
664
665        // Get update availability info
666        let update_available = self.latest_version().map(|v| v.to_string());
667
668        // Render status bar (hidden when toggled off, or when suggestions/file browser popup is shown)
669        if self.status_bar_visible && !has_suggestions && !has_file_browser {
670            // Get warning level for colored indicator (respects config setting)
671            // LSP warning level is scoped to the current buffer's language
672            let (warning_level, general_warning_count) =
673                if self.config.warnings.show_status_indicator {
674                    let lsp_level = {
675                        use crate::services::async_bridge::LspServerStatus;
676                        let mut level = WarningLevel::None;
677                        for ((lang, _), status) in &self.lsp_server_statuses {
678                            if lang == &current_language {
679                                match status {
680                                    LspServerStatus::Error => {
681                                        level = WarningLevel::Error;
682                                        break;
683                                    }
684                                    LspServerStatus::Starting | LspServerStatus::Initializing => {
685                                        if level != WarningLevel::Error {
686                                            level = WarningLevel::Warning;
687                                        }
688                                    }
689                                    _ => {}
690                                }
691                            }
692                        }
693                        level
694                    };
695                    (lsp_level, self.get_general_warning_count())
696                } else {
697                    (WarningLevel::None, 0)
698                };
699
700            // Compute status bar hover state for styling
701            use crate::view::ui::status_bar::StatusBarHover;
702            let status_bar_hover = match &self.mouse_state.hover_target {
703                Some(HoverTarget::StatusBarLspIndicator) => StatusBarHover::LspIndicator,
704                Some(HoverTarget::StatusBarWarningBadge) => StatusBarHover::WarningBadge,
705                Some(HoverTarget::StatusBarLineEndingIndicator) => {
706                    StatusBarHover::LineEndingIndicator
707                }
708                Some(HoverTarget::StatusBarEncodingIndicator) => StatusBarHover::EncodingIndicator,
709                Some(HoverTarget::StatusBarLanguageIndicator) => StatusBarHover::LanguageIndicator,
710                Some(HoverTarget::StatusBarRemoteIndicator) => StatusBarHover::RemoteIndicator,
711                _ => StatusBarHover::None,
712            };
713
714            let remote_connection = self.connection_display_string();
715
716            // Get session name for display (only in session mode)
717            let session_name = self.session_name().map(|s| s.to_string());
718
719            let active_split = self.effective_active_split();
720            let active_buf = self.active_buffer();
721            let default_cursors = crate::model::cursor::Cursors::new();
722            let status_cursors = self
723                .split_view_states
724                .get(&active_split)
725                .map(|vs| &vs.cursors)
726                .unwrap_or(&default_cursors);
727            let is_read_only = self
728                .buffer_metadata
729                .get(&active_buf)
730                .map(|m| m.read_only)
731                .unwrap_or(false);
732            let mut status_ctx = crate::view::ui::status_bar::StatusBarContext {
733                state: self.buffers.get_mut(&active_buf).unwrap(),
734                cursors: status_cursors,
735                status_message: &status_message,
736                plugin_status_message: &plugin_status_message,
737                lsp_status: &lsp_status,
738                lsp_indicator_state,
739                theme: &theme,
740                display_name: &display_name,
741                keybindings: &keybindings_cloned,
742                chord_state: &chord_state_cloned,
743                update_available: update_available.as_deref(),
744                warning_level,
745                general_warning_count,
746                hover: status_bar_hover,
747                remote_connection: remote_connection.as_deref(),
748                session_name: session_name.as_deref(),
749                read_only: is_read_only,
750                remote_state_override: self.remote_indicator_override.as_ref(),
751                // Filled in by `render_status` from the user's
752                // status_bar config; the value here is just a
753                // safe default for the rare path that builds the
754                // ctx but doesn't run `render_status`.
755                remote_indicator_on_bar: false,
756            };
757            let status_bar_layout = StatusBarRenderer::render_status_bar(
758                frame,
759                main_chunks[status_bar_idx],
760                &mut status_ctx,
761                &self.config.editor.status_bar,
762            );
763
764            // Store status bar layout for click detection
765            let status_bar_area = main_chunks[status_bar_idx];
766            self.cached_layout.status_bar_area =
767                Some((status_bar_area.y, status_bar_area.x, status_bar_area.width));
768            self.cached_layout.status_bar_lsp_area = status_bar_layout.lsp_indicator;
769            self.cached_layout.status_bar_warning_area = status_bar_layout.warning_badge;
770            self.cached_layout.status_bar_line_ending_area =
771                status_bar_layout.line_ending_indicator;
772            self.cached_layout.status_bar_encoding_area = status_bar_layout.encoding_indicator;
773            self.cached_layout.status_bar_language_area = status_bar_layout.language_indicator;
774            self.cached_layout.status_bar_message_area = status_bar_layout.message_area;
775            self.cached_layout.status_bar_remote_area = status_bar_layout.remote_indicator;
776        }
777
778        // Render search options bar when in search prompt
779        if show_search_options {
780            // Show "Confirm" option only in replace modes
781            let confirm_each = self.prompt.as_ref().and_then(|p| {
782                if matches!(
783                    p.prompt_type,
784                    PromptType::ReplaceSearch
785                        | PromptType::Replace { .. }
786                        | PromptType::QueryReplaceSearch
787                        | PromptType::QueryReplace { .. }
788                ) {
789                    Some(self.search_confirm_each)
790                } else {
791                    None
792                }
793            });
794
795            // Determine hover state for search options
796            use crate::view::ui::status_bar::SearchOptionsHover;
797            let search_options_hover = match &self.mouse_state.hover_target {
798                Some(HoverTarget::SearchOptionCaseSensitive) => SearchOptionsHover::CaseSensitive,
799                Some(HoverTarget::SearchOptionWholeWord) => SearchOptionsHover::WholeWord,
800                Some(HoverTarget::SearchOptionRegex) => SearchOptionsHover::Regex,
801                Some(HoverTarget::SearchOptionConfirmEach) => SearchOptionsHover::ConfirmEach,
802                _ => SearchOptionsHover::None,
803            };
804
805            let search_options_layout = StatusBarRenderer::render_search_options(
806                frame,
807                main_chunks[search_options_idx],
808                self.search_case_sensitive,
809                self.search_whole_word,
810                self.search_use_regex,
811                confirm_each,
812                &theme,
813                &keybindings_cloned,
814                search_options_hover,
815            );
816            self.cached_layout.search_options_layout = Some(search_options_layout);
817        } else {
818            self.cached_layout.search_options_layout = None;
819        }
820
821        // Render prompt line if active
822        if let Some(prompt) = &prompt {
823            // Use specialized renderer for file/folder open prompt to show colorized path
824            if matches!(
825                prompt.prompt_type,
826                crate::view::prompt::PromptType::OpenFile
827                    | crate::view::prompt::PromptType::SwitchProject
828            ) {
829                if let Some(file_open_state) = &self.file_open_state {
830                    StatusBarRenderer::render_file_open_prompt(
831                        frame,
832                        main_chunks[prompt_line_idx],
833                        prompt,
834                        file_open_state,
835                        &theme,
836                    );
837                } else {
838                    StatusBarRenderer::render_prompt(
839                        frame,
840                        main_chunks[prompt_line_idx],
841                        prompt,
842                        &theme,
843                    );
844                }
845            } else {
846                StatusBarRenderer::render_prompt(
847                    frame,
848                    main_chunks[prompt_line_idx],
849                    prompt,
850                    &theme,
851                );
852            }
853        }
854
855        // Render file browser popup or suggestions popup AFTER status bar + prompt,
856        // so they overlay on top of both (fixes bottom border being overwritten by status bar)
857        self.render_prompt_popups(frame, main_chunks[prompt_line_idx], size.width);
858
859        // Render popups from the active buffer state
860        // Clone theme to avoid borrow checker issues with active_state_mut()
861        let theme_clone = self.theme.clone();
862        let hover_target = self.mouse_state.hover_target.clone();
863
864        // Clear popup areas and recalculate
865        self.cached_layout.popup_areas.clear();
866
867        // Collect popup information without holding a mutable borrow
868        let popup_info: Vec<_> = {
869            // Get viewport from active split's SplitViewState
870            let active_split = self.split_manager.active_split();
871            let viewport = self
872                .split_view_states
873                .get(&active_split)
874                .map(|vs| vs.viewport.clone());
875
876            // Get the content_rect for the active split from the cached layout.
877            // This is the absolute screen rect (already accounts for file explorer,
878            // tab bar, scrollbars, etc.). The gutter is rendered inside this rect,
879            // so we add gutter_width to get the text content origin.
880            let content_rect = self
881                .cached_layout
882                .split_areas
883                .iter()
884                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
885                .map(|(_, _, rect, _, _, _)| *rect);
886
887            let primary_cursor = self
888                .split_view_states
889                .get(&active_split)
890                .map(|vs| *vs.cursors.primary());
891            let state = self.active_state_mut();
892            if state.popups.is_visible() {
893                // Get the primary cursor position for popup positioning
894                let primary_cursor =
895                    primary_cursor.unwrap_or_else(|| crate::model::cursor::Cursor::new(0));
896
897                // Compute gutter width so we know where text content starts
898                let gutter_width = viewport
899                    .as_ref()
900                    .map(|vp| vp.gutter_width(&state.buffer) as u16)
901                    .unwrap_or(0);
902
903                let cursor_screen_pos = viewport
904                    .as_ref()
905                    .map(|vp| vp.cursor_screen_position(&mut state.buffer, &primary_cursor))
906                    .unwrap_or((0, 0));
907
908                // For completion popups, compute the word-start screen position so
909                // the popup aligns with the beginning of the word being completed,
910                // not the current cursor position.
911                let word_start_screen_pos = {
912                    use crate::primitives::word_navigation::find_completion_word_start;
913                    let word_start =
914                        find_completion_word_start(&state.buffer, primary_cursor.position);
915                    let word_start_cursor = crate::model::cursor::Cursor::new(word_start);
916                    viewport
917                        .as_ref()
918                        .map(|vp| vp.cursor_screen_position(&mut state.buffer, &word_start_cursor))
919                        .unwrap_or((0, 0))
920                };
921
922                // Use content_rect as the single source of truth for the text
923                // content area origin. content_rect.x is the split's left edge
924                // (already past the file explorer), content_rect.y is below the
925                // tab bar. Adding gutter_width gives us the text content start.
926                let (base_x, base_y) = content_rect
927                    .map(|r| (r.x + gutter_width, r.y))
928                    .unwrap_or((gutter_width, 1));
929
930                let cursor_screen_pos =
931                    (cursor_screen_pos.0 + base_x, cursor_screen_pos.1 + base_y);
932                let word_start_screen_pos = (
933                    word_start_screen_pos.0 + base_x,
934                    word_start_screen_pos.1 + base_y,
935                );
936
937                // Collect popup data
938                state
939                    .popups
940                    .all()
941                    .iter()
942                    .enumerate()
943                    .map(|(popup_idx, popup)| {
944                        // Use word-start x for completion popups, cursor x for others
945                        let popup_pos = if popup.kind == crate::view::popup::PopupKind::Completion {
946                            (word_start_screen_pos.0, cursor_screen_pos.1)
947                        } else {
948                            cursor_screen_pos
949                        };
950                        let popup_area = popup.calculate_area(size, Some(popup_pos));
951
952                        // Track popup area for mouse hit testing
953                        // Account for description height when calculating the list item area
954                        let desc_height = popup.description_height();
955                        let inner_area = if popup.bordered {
956                            ratatui::layout::Rect {
957                                x: popup_area.x + 1,
958                                y: popup_area.y + 1 + desc_height,
959                                width: popup_area.width.saturating_sub(2),
960                                height: popup_area.height.saturating_sub(2 + desc_height),
961                            }
962                        } else {
963                            ratatui::layout::Rect {
964                                x: popup_area.x,
965                                y: popup_area.y + desc_height,
966                                width: popup_area.width,
967                                height: popup_area.height.saturating_sub(desc_height),
968                            }
969                        };
970
971                        let num_items = match &popup.content {
972                            crate::view::popup::PopupContent::List { items, .. } => items.len(),
973                            _ => 0,
974                        };
975
976                        // Calculate total content lines and scrollbar rect
977                        let total_lines = popup.item_count();
978                        let visible_lines = inner_area.height as usize;
979                        let scrollbar_rect = if total_lines > visible_lines && inner_area.width > 2
980                        {
981                            Some(ratatui::layout::Rect {
982                                x: inner_area.x + inner_area.width - 1,
983                                y: inner_area.y,
984                                width: 1,
985                                height: inner_area.height,
986                            })
987                        } else {
988                            None
989                        };
990
991                        (
992                            popup_idx,
993                            popup_area,
994                            inner_area,
995                            popup.scroll_offset,
996                            num_items,
997                            scrollbar_rect,
998                            total_lines,
999                        )
1000                    })
1001                    .collect()
1002            } else {
1003                Vec::new()
1004            }
1005        };
1006
1007        // Store popup areas for mouse hit testing
1008        self.cached_layout.popup_areas = popup_info.clone();
1009
1010        // Now render popups
1011        let state = self.active_state_mut();
1012        if state.popups.is_visible() {
1013            for (popup_idx, popup) in state.popups.all().iter().enumerate() {
1014                if let Some((_, popup_area, _, _, _, _, _)) = popup_info.get(popup_idx) {
1015                    popup.render_with_hover(
1016                        frame,
1017                        *popup_area,
1018                        &theme_clone,
1019                        hover_target.as_ref(),
1020                    );
1021                }
1022            }
1023        }
1024
1025        // Render editor-level popups (e.g. plugin action popups) on top of any
1026        // buffer content so they stay visible across buffer switches and over
1027        // virtual buffers (Dashboard, diagnostics) that own the whole split.
1028        // These don't need cursor-relative positioning — they all use absolute
1029        // positions like BottomRight or Centered.
1030        //
1031        // Queue semantics: concurrent action popups stack in `global_popups`,
1032        // but only the top one renders & receives input. Deeper popups
1033        // surface as the top is resolved — the alternative (drawing all at
1034        // the same BottomRight slot) makes them illegible.
1035        self.cached_layout.global_popup_areas.clear();
1036        if let Some(popup) = self.global_popups.top() {
1037            let top_idx = self.global_popups.all().len() - 1;
1038            let popup_area = popup.calculate_area(size, None);
1039            let desc_height = popup.description_height();
1040            let inner_area = if popup.bordered {
1041                ratatui::layout::Rect {
1042                    x: popup_area.x + 1,
1043                    y: popup_area.y + 1 + desc_height,
1044                    width: popup_area.width.saturating_sub(2),
1045                    height: popup_area.height.saturating_sub(2 + desc_height),
1046                }
1047            } else {
1048                ratatui::layout::Rect {
1049                    x: popup_area.x,
1050                    y: popup_area.y + desc_height,
1051                    width: popup_area.width,
1052                    height: popup_area.height.saturating_sub(desc_height),
1053                }
1054            };
1055            let num_items = match &popup.content {
1056                crate::view::popup::PopupContent::List { items, .. } => items.len(),
1057                _ => 0,
1058            };
1059            self.cached_layout.global_popup_areas.push((
1060                top_idx,
1061                popup_area,
1062                inner_area,
1063                popup.scroll_offset,
1064                num_items,
1065            ));
1066            popup.render_with_hover(frame, popup_area, &theme_clone, hover_target.as_ref());
1067        }
1068
1069        // Render menu bar last so dropdown appears on top of all other content
1070        // Update menu context with current editor state
1071        self.update_menu_context();
1072
1073        // Render settings modal (before menu bar so menus can overlay)
1074        // Check visibility first to avoid borrow conflict with dimming
1075        let settings_visible = self
1076            .settings_state
1077            .as_ref()
1078            .map(|s| s.visible)
1079            .unwrap_or(false);
1080        if settings_visible {
1081            // Dim the editor content behind the settings modal
1082            crate::view::dimming::apply_dimming(frame, size);
1083        }
1084        if let Some(ref mut settings_state) = self.settings_state {
1085            if settings_state.visible {
1086                settings_state.update_focus_states();
1087                let settings_layout = crate::view::settings::render_settings(
1088                    frame,
1089                    size,
1090                    settings_state,
1091                    &self.theme,
1092                );
1093                self.cached_layout.settings_layout = Some(settings_layout);
1094            }
1095        }
1096
1097        // Render calibration wizard if active
1098        if let Some(ref wizard) = self.calibration_wizard {
1099            // Dim the editor content behind the wizard modal
1100            crate::view::dimming::apply_dimming(frame, size);
1101            crate::view::calibration_wizard::render_calibration_wizard(
1102                frame,
1103                size,
1104                wizard,
1105                &self.theme,
1106            );
1107        }
1108
1109        // Render keybinding editor if active
1110        if let Some(ref mut kb_editor) = self.keybinding_editor {
1111            crate::view::dimming::apply_dimming(frame, size);
1112            crate::view::keybinding_editor::render_keybinding_editor(
1113                frame,
1114                size,
1115                kb_editor,
1116                &self.theme,
1117            );
1118        }
1119
1120        // Render event debug dialog if active
1121        if let Some(ref debug) = self.event_debug {
1122            // Dim the editor content behind the dialog modal
1123            crate::view::dimming::apply_dimming(frame, size);
1124            crate::view::event_debug::render_event_debug(frame, size, debug, &self.theme);
1125        }
1126
1127        if self.menu_bar_visible {
1128            let keybindings = self.keybindings.read().unwrap();
1129            self.cached_layout.menu_layout = Some(crate::view::ui::MenuRenderer::render(
1130                frame,
1131                menu_bar_area,
1132                &self.menus,
1133                &self.menu_state,
1134                &keybindings,
1135                &self.theme,
1136                self.mouse_state.hover_target.as_ref(),
1137                self.config.editor.menu_bar_mnemonics,
1138            ));
1139        } else {
1140            self.cached_layout.menu_layout = None;
1141        }
1142
1143        // Render tab context menu if open
1144        if let Some(ref menu) = self.tab_context_menu {
1145            self.render_tab_context_menu(frame, menu);
1146        }
1147
1148        if let Some(ref menu) = self.file_explorer_context_menu {
1149            self.render_file_explorer_context_menu(frame, menu);
1150        }
1151
1152        // Record non-editor region theme keys for the theme inspector
1153        self.record_non_editor_theme_regions();
1154
1155        // Render theme info popup (Ctrl+Right-Click)
1156        self.render_theme_info_popup(frame);
1157
1158        // Render tab drag drop zone overlay if dragging a tab
1159        if let Some(ref drag_state) = self.mouse_state.dragging_tab {
1160            if drag_state.is_dragging() {
1161                self.render_tab_drop_zone(frame, drag_state);
1162            }
1163        }
1164
1165        // Render software mouse cursor when GPM is active
1166        // GPM can't draw its cursor on the alternate screen buffer used by TUI apps,
1167        // so we draw our own cursor at the tracked mouse position.
1168        // This must happen LAST in the render flow so we can read the already-rendered
1169        // cell content and invert it.
1170        if self.gpm_active {
1171            if let Some((col, row)) = self.mouse_cursor_position {
1172                use ratatui::style::Modifier;
1173
1174                // Only render if within screen bounds
1175                if col < size.width && row < size.height {
1176                    // Get the cell at this position and add REVERSED modifier to invert colors
1177                    let buf = frame.buffer_mut();
1178                    if let Some(cell) = buf.cell_mut((col, row)) {
1179                        cell.set_style(cell.style().add_modifier(Modifier::REVERSED));
1180                    }
1181                }
1182            }
1183        }
1184
1185        // When keyboard capture mode is active, dim all UI elements outside the terminal
1186        // to visually indicate that focus is exclusively on the terminal
1187        if self.keyboard_capture && self.terminal_mode {
1188            // Find the active split's content area
1189            let active_split = self.split_manager.active_split();
1190            let active_split_area = self
1191                .cached_layout
1192                .split_areas
1193                .iter()
1194                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
1195                .map(|(_, _, content_rect, _, _, _)| *content_rect);
1196
1197            if let Some(terminal_area) = active_split_area {
1198                self.apply_keyboard_capture_dimming(frame, terminal_area);
1199            }
1200        }
1201
1202        // Commit the active-split hardware cursor (deferred since
1203        // `render_content`) unless a popup has been drawn over that cell.
1204        // Ratatui draws the hardware caret on top of every cell, so a
1205        // popup cannot hide the cursor by painting cells — the only way
1206        // to hide it is to leave `Frame::cursor_position` as `None`, which
1207        // triggers `Terminal::hide_cursor` at the end of the draw.
1208        //
1209        // When a prompt is active the prompt renderer already placed the
1210        // caret on the prompt line via `frame.set_cursor_position`; don't
1211        // override it with the (now-irrelevant) buffer cursor.
1212        if let Some((cx, cy)) = pending_hardware_cursor {
1213            if self.prompt.is_none() && !self.cursor_obscured_by_overlay(cx, cy) {
1214                frame.set_cursor_position((cx, cy));
1215            }
1216        }
1217
1218        // Convert all colors for terminal capability (256/16 color fallback)
1219        crate::view::color_support::convert_buffer_colors(
1220            frame.buffer_mut(),
1221            self.color_capability,
1222        );
1223
1224        // Frame-buffer animations run last so they mutate the final paint.
1225        self.animations.apply_all(frame.buffer_mut());
1226    }
1227
1228    /// Compare the hardware cursor's screen position to the previous frame's
1229    /// and, if it moved by more than the "jump" threshold, start a
1230    /// `CursorJump` animation from the old to the new on-screen position.
1231    /// Successive jumps cancel the prior animation so trail effects don't
1232    /// pile up.
1233    ///
1234    /// Cross-split and cross-buffer transitions (focus change, tab switch)
1235    /// are also animated — the trail crosses pane separators on its way
1236    /// from one buffer's cursor cell to another's.
1237    ///
1238    /// The threshold is intentionally generous: arrow-key/typing moves
1239    /// (small `dx`/`dy`) must NOT trigger the animation, but search jumps,
1240    /// goto-line/definition, and pane switches (which always cross several
1241    /// rows or many columns) must.
1242    fn maybe_start_cursor_jump_animation(
1243        &mut self,
1244        current_pos: Option<(u16, u16)>,
1245        active_split: crate::model::event::LeafId,
1246    ) {
1247        // Honour the global animations toggle. Tests default to
1248        // `animations = false` so single-tick `render()` calls observe the
1249        // settled buffer instead of a mid-flight trail; users can also
1250        // disable animations entirely from config.
1251        if !self.config.editor.animations {
1252            self.previous_cursor_screen_pos = current_pos.map(|p| (p, active_split));
1253            return;
1254        }
1255
1256        let Some(current) = current_pos else {
1257            // Cursor is hidden this frame (e.g. prompt has focus). Reset the
1258            // tracker so the re-emerging cursor doesn't animate from a stale
1259            // spot when focus returns to a buffer.
1260            self.previous_cursor_screen_pos = None;
1261            return;
1262        };
1263
1264        let prev_entry = self.previous_cursor_screen_pos;
1265        // Update tracking unconditionally for the next frame.
1266        self.previous_cursor_screen_pos = Some((current, active_split));
1267
1268        let Some((prev, prev_split)) = prev_entry else {
1269            return;
1270        };
1271        if prev == current && prev_split == active_split {
1272            return;
1273        }
1274
1275        let dx = (current.0 as i32 - prev.0 as i32).abs();
1276        let dy = (current.1 as i32 - prev.1 as i32).abs();
1277        // Animate when the cursor crossed split panes, or when it made a
1278        // non-incremental move within the same pane: more than two rows
1279        // vertically, or at least ten columns horizontally. Small hops
1280        // (typing, arrow keys, word-jump, home/end on short lines) are
1281        // intentionally skipped.
1282        let crossed_panes = prev_split != active_split;
1283        let row_jump = dy > 2;
1284        let col_jump = dx >= 10;
1285        if !crossed_panes && !row_jump && !col_jump {
1286            return;
1287        }
1288
1289        // Cancel any prior cursor-jump animation so trails don't stack.
1290        if let Some(prev_anim) = self.cursor_jump_animation.take() {
1291            self.animations.cancel(prev_anim);
1292        }
1293
1294        let id = self.animations.start(
1295            // The bounding box is for runner bookkeeping only — CursorJump
1296            // paints at absolute screen coords and ignores `area`.
1297            ratatui::layout::Rect {
1298                x: prev.0.min(current.0),
1299                y: prev.1.min(current.1),
1300                width: dx as u16 + 1,
1301                height: dy as u16 + 1,
1302            },
1303            crate::view::animation::AnimationKind::CursorJump {
1304                from: prev,
1305                to: current,
1306                duration: std::time::Duration::from_millis(140),
1307                cursor_color: self.theme.cursor,
1308                bg_color: self.theme.editor_bg,
1309            },
1310        );
1311        self.cursor_jump_animation = Some(id);
1312    }
1313
1314    /// Returns true if `(x, y)` falls inside any popup-style overlay that
1315    /// was rendered this frame. Used to decide whether the hardware cursor
1316    /// should be shown or hidden so it does not bleed through a popup.
1317    fn cursor_obscured_by_overlay(&self, x: u16, y: u16) -> bool {
1318        let inside = |rect: ratatui::layout::Rect| -> bool {
1319            x >= rect.x
1320                && x < rect.x.saturating_add(rect.width)
1321                && y >= rect.y
1322                && y < rect.y.saturating_add(rect.height)
1323        };
1324
1325        if self
1326            .cached_layout
1327            .popup_areas
1328            .iter()
1329            .any(|entry| inside(entry.1))
1330        {
1331            return true;
1332        }
1333        if self
1334            .cached_layout
1335            .global_popup_areas
1336            .iter()
1337            .any(|entry| inside(entry.1))
1338        {
1339            return true;
1340        }
1341        if let Some((rect, _, _, _)) = self.cached_layout.suggestions_area {
1342            if inside(rect) {
1343                return true;
1344            }
1345        }
1346        if let Some(ref fb) = self.file_browser_layout {
1347            if inside(fb.popup_area) {
1348                return true;
1349            }
1350        }
1351        false
1352    }
1353
1354    /// Render the Quick Open hints line showing available mode prefixes
1355    fn render_quick_open_hints(
1356        frame: &mut Frame,
1357        area: ratatui::layout::Rect,
1358        theme: &crate::view::theme::Theme,
1359    ) {
1360        use ratatui::style::{Modifier, Style};
1361        use ratatui::text::{Line, Span};
1362        use ratatui::widgets::Paragraph;
1363        use rust_i18n::t;
1364
1365        let hints_style = Style::default()
1366            .fg(theme.line_number_fg)
1367            .bg(theme.suggestion_selected_bg)
1368            .add_modifier(Modifier::DIM);
1369        let hints_text = t!("quick_open.mode_hints");
1370        // Left-align with small margin
1371        let left_margin = 2;
1372        let hints_width = crate::primitives::display_width::str_width(&hints_text);
1373        let mut spans = Vec::new();
1374        spans.push(Span::styled(" ".repeat(left_margin), hints_style));
1375        spans.push(Span::styled(hints_text.to_string(), hints_style));
1376        let remaining = (area.width as usize).saturating_sub(left_margin + hints_width);
1377        spans.push(Span::styled(" ".repeat(remaining), hints_style));
1378
1379        let paragraph = Paragraph::new(Line::from(spans));
1380        frame.render_widget(paragraph, area);
1381    }
1382
1383    /// Apply dimming effect to UI elements outside the focused terminal area
1384    /// This visually indicates that keyboard capture mode is active
1385    fn apply_keyboard_capture_dimming(
1386        &self,
1387        frame: &mut Frame,
1388        terminal_area: ratatui::layout::Rect,
1389    ) {
1390        let size = frame.area();
1391        crate::view::dimming::apply_dimming_excluding(frame, size, Some(terminal_area));
1392    }
1393
1394    /// Render file browser or suggestions popup as overlay above the prompt line.
1395    /// Called after status bar + prompt so the popup draws on top of both.
1396    fn render_prompt_popups(
1397        &mut self,
1398        frame: &mut Frame,
1399        prompt_area: ratatui::layout::Rect,
1400        width: u16,
1401    ) {
1402        let Some(prompt) = &self.prompt else { return };
1403
1404        if matches!(
1405            prompt.prompt_type,
1406            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
1407        ) {
1408            let Some(file_open_state) = &self.file_open_state else {
1409                return;
1410            };
1411            let max_height = prompt_area.y.saturating_sub(1).min(20);
1412            let popup_area = ratatui::layout::Rect {
1413                x: 0,
1414                y: prompt_area.y.saturating_sub(max_height),
1415                width,
1416                height: max_height,
1417            };
1418            let keybindings = self.keybindings.read().unwrap();
1419            self.file_browser_layout = crate::view::ui::FileBrowserRenderer::render(
1420                frame,
1421                popup_area,
1422                file_open_state,
1423                &self.theme,
1424                &self.mouse_state.hover_target,
1425                Some(&*keybindings),
1426            );
1427            return;
1428        }
1429
1430        if prompt.suggestions.is_empty() {
1431            return;
1432        }
1433
1434        let suggestion_count = prompt.suggestions.len().min(10);
1435        let is_quick_open = prompt.prompt_type == crate::view::prompt::PromptType::QuickOpen;
1436        let hints_height: u16 = if is_quick_open { 1 } else { 0 };
1437        let height = suggestion_count as u16 + 2 + hints_height;
1438
1439        let suggestions_area = ratatui::layout::Rect {
1440            x: 0,
1441            y: prompt_area.y.saturating_sub(height),
1442            width,
1443            height: height - hints_height,
1444        };
1445
1446        frame.render_widget(ratatui::widgets::Clear, suggestions_area);
1447
1448        self.cached_layout.suggestions_area = SuggestionsRenderer::render_with_hover(
1449            frame,
1450            suggestions_area,
1451            prompt,
1452            &self.theme,
1453            self.mouse_state.hover_target.as_ref(),
1454        );
1455
1456        if is_quick_open {
1457            let hints_area = ratatui::layout::Rect {
1458                x: 0,
1459                y: prompt_area.y.saturating_sub(hints_height),
1460                width,
1461                height: hints_height,
1462            };
1463            frame.render_widget(ratatui::widgets::Clear, hints_area);
1464            Self::render_quick_open_hints(frame, hints_area, &self.theme);
1465        }
1466    }
1467
1468    /// Render hover highlights for interactive elements (separators, scrollbars)
1469    pub(super) fn render_hover_highlights(&self, frame: &mut Frame) {
1470        use ratatui::style::Style;
1471        use ratatui::text::Span;
1472        use ratatui::widgets::Paragraph;
1473
1474        match &self.mouse_state.hover_target {
1475            Some(HoverTarget::SplitSeparator(split_id, direction)) => {
1476                // Highlight the separator with hover color
1477                for (sid, dir, x, y, length) in &self.cached_layout.separator_areas {
1478                    if sid == split_id && dir == direction {
1479                        let hover_style = Style::default().fg(self.theme.split_separator_hover_fg);
1480                        match dir {
1481                            SplitDirection::Horizontal => {
1482                                let line_text = "─".repeat(*length as usize);
1483                                let paragraph =
1484                                    Paragraph::new(Span::styled(line_text, hover_style));
1485                                frame.render_widget(
1486                                    paragraph,
1487                                    ratatui::layout::Rect::new(*x, *y, *length, 1),
1488                                );
1489                            }
1490                            SplitDirection::Vertical => {
1491                                for offset in 0..*length {
1492                                    let paragraph = Paragraph::new(Span::styled("│", hover_style));
1493                                    frame.render_widget(
1494                                        paragraph,
1495                                        ratatui::layout::Rect::new(*x, y + offset, 1, 1),
1496                                    );
1497                                }
1498                            }
1499                        }
1500                    }
1501                }
1502            }
1503            Some(HoverTarget::ScrollbarThumb(split_id)) => {
1504                // Highlight scrollbar thumb
1505                for (sid, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
1506                    &self.cached_layout.split_areas
1507                {
1508                    if sid == split_id {
1509                        let hover_style = Style::default().bg(self.theme.scrollbar_thumb_hover_fg);
1510                        for row_offset in *thumb_start..*thumb_end {
1511                            let paragraph = Paragraph::new(Span::styled(" ", hover_style));
1512                            frame.render_widget(
1513                                paragraph,
1514                                ratatui::layout::Rect::new(
1515                                    scrollbar_rect.x,
1516                                    scrollbar_rect.y + row_offset as u16,
1517                                    1,
1518                                    1,
1519                                ),
1520                            );
1521                        }
1522                    }
1523                }
1524            }
1525            Some(HoverTarget::ScrollbarTrack(split_id, hovered_row)) => {
1526                // Highlight only the hovered cell on the scrollbar track
1527                for (sid, _buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
1528                    &self.cached_layout.split_areas
1529                {
1530                    if sid == split_id {
1531                        let track_hover_style =
1532                            Style::default().bg(self.theme.scrollbar_track_hover_fg);
1533                        let paragraph = Paragraph::new(Span::styled(" ", track_hover_style));
1534                        frame.render_widget(
1535                            paragraph,
1536                            ratatui::layout::Rect::new(
1537                                scrollbar_rect.x,
1538                                scrollbar_rect.y + hovered_row,
1539                                1,
1540                                1,
1541                            ),
1542                        );
1543                    }
1544                }
1545            }
1546            Some(HoverTarget::FileExplorerBorder) => {
1547                // Highlight the file explorer border for resize
1548                if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1549                    let hover_style = Style::default().fg(self.theme.split_separator_hover_fg);
1550                    let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
1551                    for row_offset in 0..explorer_area.height {
1552                        let paragraph = Paragraph::new(Span::styled("│", hover_style));
1553                        frame.render_widget(
1554                            paragraph,
1555                            ratatui::layout::Rect::new(
1556                                border_x,
1557                                explorer_area.y + row_offset,
1558                                1,
1559                                1,
1560                            ),
1561                        );
1562                    }
1563                }
1564            }
1565            // Menu hover is handled by MenuRenderer
1566            _ => {}
1567        }
1568    }
1569
1570    /// Render the tab context menu
1571    fn render_tab_context_menu(&self, frame: &mut Frame, menu: &TabContextMenu) {
1572        use ratatui::style::Style;
1573        use ratatui::text::{Line, Span};
1574        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1575
1576        let items = super::types::TabContextMenuItem::all();
1577        let menu_width = 22u16; // "Close to the Right" + padding
1578        let menu_height = items.len() as u16 + 2; // items + borders
1579
1580        // Adjust position to stay within screen bounds
1581        let screen_width = frame.area().width;
1582        let screen_height = frame.area().height;
1583
1584        let menu_x = if menu.position.0 + menu_width > screen_width {
1585            screen_width.saturating_sub(menu_width)
1586        } else {
1587            menu.position.0
1588        };
1589
1590        let menu_y = if menu.position.1 + menu_height > screen_height {
1591            screen_height.saturating_sub(menu_height)
1592        } else {
1593            menu.position.1
1594        };
1595
1596        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
1597
1598        // Clear the area first
1599        frame.render_widget(Clear, area);
1600
1601        // Build the menu lines
1602        let mut lines = Vec::new();
1603        for (idx, item) in items.iter().enumerate() {
1604            let is_highlighted = idx == menu.highlighted;
1605
1606            let style = if is_highlighted {
1607                Style::default()
1608                    .fg(self.theme.menu_highlight_fg)
1609                    .bg(self.theme.menu_highlight_bg)
1610            } else {
1611                Style::default()
1612                    .fg(self.theme.menu_dropdown_fg)
1613                    .bg(self.theme.menu_dropdown_bg)
1614            };
1615
1616            // Pad the label to fill the menu width
1617            let label = item.label();
1618            let content_width = (menu_width as usize).saturating_sub(2); // -2 for borders
1619            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
1620
1621            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
1622        }
1623
1624        let block = Block::default()
1625            .borders(Borders::ALL)
1626            .border_style(Style::default().fg(self.theme.menu_border_fg))
1627            .style(Style::default().bg(self.theme.menu_dropdown_bg));
1628
1629        let paragraph = Paragraph::new(lines).block(block);
1630        frame.render_widget(paragraph, area);
1631    }
1632
1633    /// Render the file explorer context menu
1634    fn render_file_explorer_context_menu(
1635        &self,
1636        frame: &mut Frame,
1637        menu: &super::types::FileExplorerContextMenu,
1638    ) {
1639        use ratatui::style::Style;
1640        use ratatui::text::{Line, Span};
1641        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1642
1643        let items = menu.items();
1644        let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
1645        let menu_height = menu.height();
1646        let (menu_x, menu_y) = menu.clamped_position(frame.area().width, frame.area().height);
1647
1648        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
1649
1650        frame.render_widget(Clear, area);
1651
1652        let mut lines = Vec::new();
1653        for (idx, item) in items.iter().enumerate() {
1654            let is_highlighted = idx == menu.highlighted;
1655
1656            let style = if is_highlighted {
1657                Style::default()
1658                    .fg(self.theme.menu_highlight_fg)
1659                    .bg(self.theme.menu_highlight_bg)
1660            } else {
1661                Style::default()
1662                    .fg(self.theme.menu_dropdown_fg)
1663                    .bg(self.theme.menu_dropdown_bg)
1664            };
1665
1666            let label = item.label();
1667            let content_width = (menu_width as usize).saturating_sub(2);
1668            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
1669
1670            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
1671        }
1672
1673        let block = Block::default()
1674            .borders(Borders::ALL)
1675            .border_style(Style::default().fg(self.theme.menu_border_fg))
1676            .style(Style::default().bg(self.theme.menu_dropdown_bg));
1677
1678        let paragraph = Paragraph::new(lines).block(block);
1679        frame.render_widget(paragraph, area);
1680    }
1681
1682    /// Render the tab drag drop zone overlay
1683    fn render_tab_drop_zone(&self, frame: &mut Frame, drag_state: &super::types::TabDragState) {
1684        use ratatui::style::Modifier;
1685
1686        let Some(ref drop_zone) = drag_state.drop_zone else {
1687            return;
1688        };
1689
1690        let split_id = drop_zone.split_id();
1691
1692        // Find the content area for the target split
1693        let split_area = self
1694            .cached_layout
1695            .split_areas
1696            .iter()
1697            .find(|(sid, _, _, _, _, _)| *sid == split_id)
1698            .map(|(_, _, content_rect, _, _, _)| *content_rect);
1699
1700        let Some(content_rect) = split_area else {
1701            return;
1702        };
1703
1704        // Determine the highlight area based on drop zone type
1705        use super::types::TabDropZone;
1706
1707        let highlight_area = match drop_zone {
1708            TabDropZone::TabBar(_, _) | TabDropZone::SplitCenter(_) => {
1709                // For tab bar and center drops, highlight the entire split area
1710                // This indicates the tab will be added to this split's tab bar
1711                content_rect
1712            }
1713            TabDropZone::SplitLeft(_) => {
1714                // Left 50% of the split (matches the actual split size created)
1715                let width = (content_rect.width / 2).max(3);
1716                ratatui::layout::Rect::new(
1717                    content_rect.x,
1718                    content_rect.y,
1719                    width,
1720                    content_rect.height,
1721                )
1722            }
1723            TabDropZone::SplitRight(_) => {
1724                // Right 50% of the split (matches the actual split size created)
1725                let width = (content_rect.width / 2).max(3);
1726                let x = content_rect.x + content_rect.width - width;
1727                ratatui::layout::Rect::new(x, content_rect.y, width, content_rect.height)
1728            }
1729            TabDropZone::SplitTop(_) => {
1730                // Top 50% of the split (matches the actual split size created)
1731                let height = (content_rect.height / 2).max(2);
1732                ratatui::layout::Rect::new(
1733                    content_rect.x,
1734                    content_rect.y,
1735                    content_rect.width,
1736                    height,
1737                )
1738            }
1739            TabDropZone::SplitBottom(_) => {
1740                // Bottom 50% of the split (matches the actual split size created)
1741                let height = (content_rect.height / 2).max(2);
1742                let y = content_rect.y + content_rect.height - height;
1743                ratatui::layout::Rect::new(content_rect.x, y, content_rect.width, height)
1744            }
1745        };
1746
1747        // Draw the overlay with the drop zone color
1748        // We apply a semi-transparent effect by modifying existing cells
1749        let buf = frame.buffer_mut();
1750        let drop_zone_bg = self.theme.tab_drop_zone_bg;
1751        let drop_zone_border = self.theme.tab_drop_zone_border;
1752
1753        // Fill the highlight area with a semi-transparent overlay
1754        for y in highlight_area.y..highlight_area.y + highlight_area.height {
1755            for x in highlight_area.x..highlight_area.x + highlight_area.width {
1756                if let Some(cell) = buf.cell_mut((x, y)) {
1757                    // Blend the drop zone color with the existing background
1758                    // For a simple effect, we just set the background
1759                    cell.set_bg(drop_zone_bg);
1760
1761                    // Draw border on edges
1762                    let is_border = x == highlight_area.x
1763                        || x == highlight_area.x + highlight_area.width - 1
1764                        || y == highlight_area.y
1765                        || y == highlight_area.y + highlight_area.height - 1;
1766
1767                    if is_border {
1768                        cell.set_fg(drop_zone_border);
1769                        cell.set_style(cell.style().add_modifier(Modifier::BOLD));
1770                    }
1771                }
1772            }
1773        }
1774
1775        // Draw a border indicator based on the zone type
1776        match drop_zone {
1777            TabDropZone::SplitLeft(_) => {
1778                // Draw vertical indicator on left edge
1779                for y in highlight_area.y..highlight_area.y + highlight_area.height {
1780                    if let Some(cell) = buf.cell_mut((highlight_area.x, y)) {
1781                        cell.set_symbol("▌");
1782                        cell.set_fg(drop_zone_border);
1783                    }
1784                }
1785            }
1786            TabDropZone::SplitRight(_) => {
1787                // Draw vertical indicator on right edge
1788                let x = highlight_area.x + highlight_area.width - 1;
1789                for y in highlight_area.y..highlight_area.y + highlight_area.height {
1790                    if let Some(cell) = buf.cell_mut((x, y)) {
1791                        cell.set_symbol("▐");
1792                        cell.set_fg(drop_zone_border);
1793                    }
1794                }
1795            }
1796            TabDropZone::SplitTop(_) => {
1797                // Draw horizontal indicator on top edge
1798                for x in highlight_area.x..highlight_area.x + highlight_area.width {
1799                    if let Some(cell) = buf.cell_mut((x, highlight_area.y)) {
1800                        cell.set_symbol("▀");
1801                        cell.set_fg(drop_zone_border);
1802                    }
1803                }
1804            }
1805            TabDropZone::SplitBottom(_) => {
1806                // Draw horizontal indicator on bottom edge
1807                let y = highlight_area.y + highlight_area.height - 1;
1808                for x in highlight_area.x..highlight_area.x + highlight_area.width {
1809                    if let Some(cell) = buf.cell_mut((x, y)) {
1810                        cell.set_symbol("▄");
1811                        cell.set_fg(drop_zone_border);
1812                    }
1813                }
1814            }
1815            TabDropZone::SplitCenter(_) | TabDropZone::TabBar(_, _) => {
1816                // For center and tab bar, the filled background is sufficient
1817            }
1818        }
1819    }
1820
1821    /// Recompute the view_line_mappings layout without drawing.
1822    /// Used during macro replay so that visual-line movements (MoveLineEnd,
1823    /// MoveUp, MoveDown on wrapped lines) see correct, up-to-date layout
1824    /// information between each replayed action.
1825    pub fn recompute_layout(&mut self, width: u16, height: u16) {
1826        let size = ratatui::layout::Rect::new(0, 0, width, height);
1827
1828        // Replicate the pre-render sync steps from render()
1829        let active_split = self.split_manager.active_split();
1830        self.pre_sync_ensure_visible(active_split);
1831        self.sync_scroll_groups();
1832
1833        // Replicate the layout computation that produces editor_content_area.
1834        // Same constraints as render(): [menu_bar, main_content, status_bar, search_options, prompt_line]
1835        let constraints = vec![
1836            Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }),
1837            Constraint::Min(0),
1838            Constraint::Length(if self.status_bar_visible { 1 } else { 0 }), // status bar
1839            Constraint::Length(0), // search options (doesn't matter for layout)
1840            Constraint::Length(if self.prompt_line_visible { 1 } else { 0 }), // prompt line
1841        ];
1842        let main_chunks = Layout::default()
1843            .direction(Direction::Vertical)
1844            .constraints(constraints)
1845            .split(size);
1846        let main_content_area = main_chunks[1];
1847
1848        // Compute editor_content_area (with file explorer split if visible)
1849        let file_explorer_should_show = self.file_explorer_visible
1850            && (self.file_explorer.is_some() || self.file_explorer_sync_in_progress);
1851        let editor_content_area = if file_explorer_should_show {
1852            let explorer_cols = self.file_explorer_width.to_cols(main_content_area.width);
1853            let horizontal_chunks = Layout::default()
1854                .direction(Direction::Horizontal)
1855                .constraints([Constraint::Length(explorer_cols), Constraint::Min(0)])
1856                .split(main_content_area);
1857            horizontal_chunks[1]
1858        } else {
1859            main_content_area
1860        };
1861
1862        // Compute layout for all visible splits and update cached view_line_mappings
1863        let view_line_mappings = SplitRenderer::compute_content_layout(
1864            editor_content_area,
1865            &self.split_manager,
1866            &mut self.buffers,
1867            &mut self.split_view_states,
1868            &self.theme,
1869            false, // lsp_waiting — not relevant for layout
1870            self.config.editor.estimated_line_length,
1871            self.config.editor.highlight_context_bytes,
1872            self.config.editor.relative_line_numbers,
1873            self.config.editor.use_terminal_bg,
1874            self.session_mode || !self.software_cursor_only,
1875            self.software_cursor_only,
1876            self.tab_bar_visible,
1877            self.config.editor.show_vertical_scrollbar,
1878            self.config.editor.show_horizontal_scrollbar,
1879            self.config.editor.diagnostics_inline_text,
1880            self.config.editor.show_tilde,
1881        );
1882
1883        self.cached_layout.view_line_mappings = view_line_mappings;
1884    }
1885
1886    /// Clear the search history
1887    /// Used primarily for testing to ensure test isolation
1888    pub fn clear_search_history(&mut self) {
1889        if let Some(history) = self.prompt_histories.get_mut("search") {
1890            history.clear();
1891        }
1892    }
1893
1894    /// Emit an OSC 2 escape sequence to set the host terminal's window/tab
1895    /// title based on the active buffer's display name. Deduplicated against
1896    /// the last title we wrote so we don't spam stdout every frame.
1897    ///
1898    /// Gated by `editor.set_window_title` (default on). Terminals that
1899    /// don't implement OSC 2 silently drop the sequence.
1900    fn update_terminal_title(&mut self, display_name: &str) {
1901        if !self.config.editor.set_window_title {
1902            return;
1903        }
1904        let new_title = format!("{} \u{2014} Fresh", display_name);
1905        if self.last_window_title.as_deref() == Some(new_title.as_str()) {
1906            return;
1907        }
1908        crate::services::terminal_title::write_terminal_title(&new_title);
1909        self.last_window_title = Some(new_title);
1910    }
1911
1912    /// Save all prompt histories to disk
1913    /// Called on shutdown to persist history across sessions
1914    pub fn save_histories(&self) {
1915        // Ensure data directory exists
1916        if let Err(e) = self
1917            .authority
1918            .filesystem
1919            .create_dir_all(&self.dir_context.data_dir)
1920        {
1921            tracing::warn!("Failed to create data directory: {}", e);
1922            return;
1923        }
1924
1925        // Save all prompt histories
1926        for (key, history) in &self.prompt_histories {
1927            let path = self.dir_context.prompt_history_path(key);
1928            if let Err(e) = history.save_to_file(&path) {
1929                tracing::warn!("Failed to save {} history: {}", key, e);
1930            } else {
1931                tracing::debug!("Saved {} history to {:?}", key, path);
1932            }
1933        }
1934    }
1935}