Skip to main content

fresh/view/ui/
status_bar.rs

1//! Status bar and prompt/minibuffer rendering
2
3use std::path::Path;
4
5use crate::app::WarningLevel;
6use crate::config::{StatusBarConfig, StatusBarElement};
7use crate::primitives::display_width::{char_width, str_width};
8use crate::state::EditorState;
9use crate::view::prompt::Prompt;
10use chrono::Timelike;
11use ratatui::layout::Rect;
12use ratatui::style::{Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::Paragraph;
15use ratatui::Frame;
16use rust_i18n::t;
17
18/// Text that both marks a buffer as "edited over a disconnected SSH session"
19/// and styles the prefix in the status bar. Kept as constants so `render_element`
20/// and `element_spans` stay in sync.
21const SSH_PREFIX: &str = "[SSH:";
22const SSH_PREFIX_TERMINATOR: &str = "] ";
23
24/// Categorization of how a rendered element should be styled and tracked for click detection.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26enum ElementKind {
27    /// Normal text using base status bar colors
28    Normal,
29    /// Line ending indicator (clickable)
30    LineEnding,
31    /// Encoding indicator (clickable)
32    Encoding,
33    /// Language indicator (clickable)
34    Language,
35    /// LSP status indicator (colored by warning level, clickable)
36    Lsp,
37    /// Warning badge (colored, clickable)
38    WarningBadge,
39    /// Update available indicator (highlighted)
40    Update,
41    /// Command palette shortcut hint (distinct style)
42    Palette,
43    /// Status message area (clickable to show history)
44    Messages,
45    /// Remote disconnected prefix (error colors)
46    RemoteDisconnected,
47    /// Clock element — colon rendered with hardware blink
48    Clock,
49    /// Remote authority indicator — styling driven by connection state
50    RemoteIndicator(RemoteIndicatorState),
51}
52
53/// Visual/semantic state of the remote authority indicator.
54///
55/// Covers the full dev-container UX lifecycle the spec asks for —
56/// Local, Connecting to a remote authority, Connected, FailedAttach,
57/// Disconnected — while remaining general enough for any remote
58/// authority Fresh currently supports (SSH today; containers;
59/// anything a plugin installs via `editor.setAuthority(...)`).
60///
61/// Variants deliberately hold no data — phase labels and error text
62/// are passed alongside via `StatusBarContext::remote_state_override`
63/// (added in Phase B-2) so the enum stays `Copy` and core code never
64/// learns devcontainer-specific vocabulary (see
65/// `AUTHORITY_DESIGN.md` principle 3).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum RemoteIndicatorState {
68    /// Editing local files; rendered with the default status-bar palette.
69    #[default]
70    Local,
71    /// An attach (or reconnect) is in flight — rendered with a spinner
72    /// glyph and the help-indicator palette. Plugins drive this via
73    /// `setRemoteIndicatorState` before kicking off `devcontainer up`
74    /// or similar long-running setup.
75    Connecting,
76    /// Connected to an SSH / container / other remote authority.
77    Connected,
78    /// The last attach attempt failed. Rendered with the error palette
79    /// so the state is visible at a glance; the popup surfaces the
80    /// error detail and a Retry action.
81    FailedAttach,
82    /// Connection lost — rendered with the error palette as a persistent
83    /// warning that writes/saves are no longer reaching the authority.
84    Disconnected,
85}
86
87/// Plugin-supplied override for the Remote Indicator. Carries both
88/// the state enum and a user-visible label/error text so core doesn't
89/// need to know how to phrase "Connecting..." or a specific failure
90/// string — the plugin owns the copy.
91///
92/// Deserialized from the tagged JSON shape accepted by the
93/// `SetRemoteIndicatorState` plugin op (see `fresh-core::api`). Kept
94/// in the view crate so the enum lives next to the rendering that
95/// consumes it.
96#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
97#[serde(tag = "kind", rename_all = "snake_case")]
98pub enum RemoteIndicatorOverride {
99    /// Force the indicator to "Local" even when the authority would
100    /// otherwise read as Connected. Rarely needed in practice.
101    Local,
102    /// Attach is in flight. `label` is the short text shown next to
103    /// the spinner glyph (e.g. "Building", "Pulling image").
104    Connecting {
105        #[serde(default)]
106        label: Option<String>,
107    },
108    /// Force Connected. `label` overrides the authority's display
109    /// string if present; otherwise the derived label is shown.
110    Connected {
111        #[serde(default)]
112        label: Option<String>,
113    },
114    /// Last attach attempt failed. `error` is the short message the
115    /// indicator renders; longer context belongs in the popup.
116    FailedAttach {
117        #[serde(default)]
118        error: Option<String>,
119    },
120    /// Explicitly disconnected (e.g. plugin detected a container
121    /// stop that the authority doesn't know about yet).
122    Disconnected {
123        #[serde(default)]
124        label: Option<String>,
125    },
126}
127
128impl RemoteIndicatorOverride {
129    /// Project into the Copy enum consumed by `element_style`.
130    pub fn state(&self) -> RemoteIndicatorState {
131        match self {
132            Self::Local => RemoteIndicatorState::Local,
133            Self::Connecting { .. } => RemoteIndicatorState::Connecting,
134            Self::Connected { .. } => RemoteIndicatorState::Connected,
135            Self::FailedAttach { .. } => RemoteIndicatorState::FailedAttach,
136            Self::Disconnected { .. } => RemoteIndicatorState::Disconnected,
137        }
138    }
139
140    /// Short label rendered inside the indicator element. Defaults
141    /// are chosen so an override with no `label`/`error` field still
142    /// displays something sensible.
143    pub fn label(&self) -> String {
144        match self {
145            Self::Local => "Local".to_string(),
146            Self::Connecting { label } => match label {
147                Some(s) if !s.is_empty() => format!("⠿ {}", s),
148                _ => "⠿ Connecting".to_string(),
149            },
150            Self::Connected { label } => label
151                .as_deref()
152                .filter(|s| !s.is_empty())
153                .unwrap_or("Connected")
154                .to_string(),
155            Self::FailedAttach { error } => match error {
156                Some(s) if !s.is_empty() => format!("Attach failed: {}", s),
157                _ => "Attach failed".to_string(),
158            },
159            Self::Disconnected { label } => match label {
160                Some(s) if !s.is_empty() => format!("{} (Disconnected)", s),
161                _ => "Disconnected".to_string(),
162            },
163        }
164    }
165}
166
167/// A single rendered status bar element with its text and styling info.
168struct RenderedElement {
169    text: String,
170    kind: ElementKind,
171}
172
173/// Three-state LSP status used by the status bar `Lsp` element.
174///
175/// Collapses the previous "running / auto_start-dormant / opt-in-dormant /
176/// nothing" fan-out into the three user-meaningful buckets the indicator
177/// actually needs to communicate:
178///
179/// - `On`            — at least one server for this language is running
180/// - `Off`           — configured servers exist for this language, none are running
181/// - `OffDismissed`  — like `Off`, but the user clicked "Disable" from the
182///                     popup; rendered with a muted style so it stops
183///                     shouting for attention while remaining clickable
184///                     (so the user can still open the popup to re-enable
185///                     or see install help).
186/// - `Error`         — at least one server for this language is in the Error state
187/// - `None`          — no LSP configured or running for this language
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
189pub enum LspIndicatorState {
190    #[default]
191    None,
192    On,
193    Off,
194    OffDismissed,
195    Error,
196}
197
198/// Editor state, theming, and runtime inputs needed to render a status bar frame.
199pub struct StatusBarContext<'a> {
200    pub state: &'a mut EditorState,
201    pub cursors: &'a crate::model::cursor::Cursors,
202    pub status_message: &'a Option<String>,
203    pub plugin_status_message: &'a Option<String>,
204    pub lsp_status: &'a str,
205    /// Three-state LSP indicator: On / Off / Error / None.  Drives the
206    /// indicator's background color independently of `warning_level` (the
207    /// latter still scopes whether a warning badge is shown on the right
208    /// side of the status bar).
209    pub lsp_indicator_state: LspIndicatorState,
210    pub theme: &'a crate::view::theme::Theme,
211    pub display_name: &'a str,
212    pub keybindings: &'a crate::input::keybindings::KeybindingResolver,
213    pub chord_state: &'a [(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
214    pub update_available: Option<&'a str>,
215    pub warning_level: WarningLevel,
216    pub general_warning_count: usize,
217    pub hover: StatusBarHover,
218    pub remote_connection: Option<&'a str>,
219    pub session_name: Option<&'a str>,
220    pub read_only: bool,
221    /// Plugin-supplied override for the `{remote}` indicator. When
222    /// `Some`, its state+label are rendered instead of the one
223    /// derived from `remote_connection`. Set via the
224    /// `SetRemoteIndicatorState` plugin op; cleared by
225    /// `ClearRemoteIndicatorState` or by a `None` pass at the call
226    /// site.
227    pub remote_state_override: Option<&'a RemoteIndicatorOverride>,
228    /// True when the user's status-bar layout contains the
229    /// `RemoteIndicator` element. Set by the renderer after
230    /// inspecting `StatusBarConfig.left` / `.right`. Read by the
231    /// `Filename` element's branch to decide whether to emit the
232    /// legacy `[Container:<id>] ` / SSH prefix on the filename
233    /// — when the dedicated indicator is on the bar that prefix
234    /// is redundant; when it's not, the filename keeps the prefix
235    /// so users still see the connection at a glance.
236    pub remote_indicator_on_bar: bool,
237}
238
239/// Layout information returned from status bar rendering for mouse click detection
240#[derive(Debug, Clone, Default)]
241pub struct StatusBarLayout {
242    /// LSP indicator area (row, start_col, end_col) - None if no LSP indicator shown
243    pub lsp_indicator: Option<(u16, u16, u16)>,
244    /// Warning badge area (row, start_col, end_col) - None if no warnings
245    pub warning_badge: Option<(u16, u16, u16)>,
246    /// Line ending indicator area (row, start_col, end_col)
247    pub line_ending_indicator: Option<(u16, u16, u16)>,
248    /// Encoding indicator area (row, start_col, end_col)
249    pub encoding_indicator: Option<(u16, u16, u16)>,
250    /// Language indicator area (row, start_col, end_col)
251    pub language_indicator: Option<(u16, u16, u16)>,
252    /// Status message area (row, start_col, end_col) - clickable to show full history
253    pub message_area: Option<(u16, u16, u16)>,
254    /// Remote authority indicator area (row, start_col, end_col) - clickable
255    /// to open the remote-authority context menu.
256    pub remote_indicator: Option<(u16, u16, u16)>,
257}
258
259/// Status bar hover state for styling clickable indicators
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
261pub enum StatusBarHover {
262    #[default]
263    None,
264    /// Mouse is over the LSP indicator
265    LspIndicator,
266    /// Mouse is over the warning badge
267    WarningBadge,
268    /// Mouse is over the line ending indicator
269    LineEndingIndicator,
270    /// Mouse is over the encoding indicator
271    EncodingIndicator,
272    /// Mouse is over the language indicator
273    LanguageIndicator,
274    /// Mouse is over the status message area
275    MessageArea,
276    /// Mouse is over the remote authority indicator
277    RemoteIndicator,
278}
279
280/// Which search option checkbox is being hovered
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
282pub enum SearchOptionsHover {
283    #[default]
284    None,
285    CaseSensitive,
286    WholeWord,
287    Regex,
288    ConfirmEach,
289}
290
291/// Layout information for search options bar hit testing
292#[derive(Debug, Clone, Default)]
293pub struct SearchOptionsLayout {
294    /// Row where the search options are rendered
295    pub row: u16,
296    /// Case Sensitive checkbox area (start_col, end_col)
297    pub case_sensitive: Option<(u16, u16)>,
298    /// Whole Word checkbox area (start_col, end_col)
299    pub whole_word: Option<(u16, u16)>,
300    /// Regex checkbox area (start_col, end_col)
301    pub regex: Option<(u16, u16)>,
302    /// Confirm Each checkbox area (start_col, end_col) - only present in replace mode
303    pub confirm_each: Option<(u16, u16)>,
304}
305
306impl SearchOptionsLayout {
307    /// Check which search option checkbox (if any) is at the given position
308    pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
309        if y != self.row {
310            return None;
311        }
312
313        if let Some((start, end)) = self.case_sensitive {
314            if x >= start && x < end {
315                return Some(SearchOptionsHover::CaseSensitive);
316            }
317        }
318        if let Some((start, end)) = self.whole_word {
319            if x >= start && x < end {
320                return Some(SearchOptionsHover::WholeWord);
321            }
322        }
323        if let Some((start, end)) = self.regex {
324            if x >= start && x < end {
325                return Some(SearchOptionsHover::Regex);
326            }
327        }
328        if let Some((start, end)) = self.confirm_each {
329            if x >= start && x < end {
330                return Some(SearchOptionsHover::ConfirmEach);
331            }
332        }
333        None
334    }
335}
336
337/// Result of truncating a path for display
338#[derive(Debug, Clone)]
339pub struct TruncatedPath {
340    /// The first component of the path (e.g., "/home" or "C:\")
341    pub prefix: String,
342    /// Whether truncation occurred (if true, display "[...]" between prefix and suffix)
343    pub truncated: bool,
344    /// The last components of the path (e.g., "project/src")
345    pub suffix: String,
346}
347
348impl TruncatedPath {
349    /// Get the full display string (without styling)
350    pub fn to_string_plain(&self) -> String {
351        if self.truncated {
352            format!("{}/[...]{}", self.prefix, self.suffix)
353        } else {
354            format!("{}{}", self.prefix, self.suffix)
355        }
356    }
357
358    /// Get the display length
359    pub fn display_len(&self) -> usize {
360        if self.truncated {
361            self.prefix.len() + "/[...]".len() + self.suffix.len()
362        } else {
363            self.prefix.len() + self.suffix.len()
364        }
365    }
366}
367
368/// Truncate a path for display, showing the first component, [...], and last components
369///
370/// For example, `/private/var/folders/p6/nlmq.../T/.tmpNYt4Fc/project/file.txt`
371/// becomes `/private/[...]/project/file.txt`
372///
373/// # Arguments
374/// * `path` - The path to truncate
375/// * `max_len` - Maximum length for the display string
376///
377/// # Returns
378/// A TruncatedPath struct with prefix, truncation indicator, and suffix
379pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
380    let path_str = path.to_string_lossy();
381
382    // If path fits, return as-is
383    if path_str.len() <= max_len {
384        return TruncatedPath {
385            prefix: String::new(),
386            truncated: false,
387            suffix: path_str.to_string(),
388        };
389    }
390
391    let components: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect();
392
393    if components.is_empty() {
394        return TruncatedPath {
395            prefix: "/".to_string(),
396            truncated: false,
397            suffix: String::new(),
398        };
399    }
400
401    // Always keep the root and first component as prefix
402    let prefix = if path_str.starts_with('/') {
403        format!("/{}", components.first().unwrap_or(&""))
404    } else {
405        components.first().unwrap_or(&"").to_string()
406    };
407
408    // The "[...]/" takes 6 characters
409    let ellipsis_len = "/[...]".len();
410
411    // Calculate how much space we have for the suffix
412    let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
413
414    if available_for_suffix < 5 || components.len() <= 1 {
415        // Not enough space or only one component, just truncate the end.
416        // Walk back to a char boundary so paths with non-ASCII components
417        // (e.g. `/home/ユーザー/project`) don't byte-slice through a
418        // multi-byte UTF-8 sequence and panic (same class as #1718).
419        let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
420            let cut = path_str.floor_char_boundary(max_len.saturating_sub(3));
421            format!("{}...", &path_str[..cut])
422        } else {
423            path_str.to_string()
424        };
425        return TruncatedPath {
426            prefix: String::new(),
427            truncated: false,
428            suffix: truncated_path,
429        };
430    }
431
432    // Build suffix from the last components that fit
433    let mut suffix_parts: Vec<&str> = Vec::new();
434    let mut suffix_len = 0;
435
436    for component in components.iter().skip(1).rev() {
437        let component_len = component.len() + 1; // +1 for the '/'
438        if suffix_len + component_len <= available_for_suffix {
439            suffix_parts.push(component);
440            suffix_len += component_len;
441        } else {
442            break;
443        }
444    }
445
446    suffix_parts.reverse();
447
448    // If we included all remaining components, no truncation needed
449    if suffix_parts.len() == components.len() - 1 {
450        return TruncatedPath {
451            prefix: String::new(),
452            truncated: false,
453            suffix: path_str.to_string(),
454        };
455    }
456
457    let suffix = if suffix_parts.is_empty() {
458        // Can't fit any suffix components, truncate the last component.
459        // floor_char_boundary keeps the slice on a valid UTF-8 boundary
460        // when `last` contains non-ASCII characters.
461        let last = components.last().unwrap_or(&"");
462        let truncate_to = available_for_suffix.saturating_sub(4); // "/.." and some chars
463        if truncate_to > 0 && last.len() > truncate_to {
464            let cut = last.floor_char_boundary(truncate_to);
465            format!("/{}...", &last[..cut])
466        } else {
467            format!("/{}", last)
468        }
469    } else {
470        format!("/{}", suffix_parts.join("/"))
471    };
472
473    TruncatedPath {
474        prefix,
475        truncated: true,
476        suffix,
477    }
478}
479
480/// Truncate a string to fit within `max_width` display columns, appending "..." if truncated.
481fn truncate_to_width(s: &str, max_width: usize) -> String {
482    let width = str_width(s);
483    if width <= max_width {
484        return s.to_string();
485    }
486    let truncate_at = max_width.saturating_sub(3);
487    if truncate_at == 0 {
488        return if max_width >= 3 {
489            "...".to_string()
490        } else {
491            s.chars().take(max_width).collect()
492        };
493    }
494    let mut w = 0;
495    let truncated: String = s
496        .chars()
497        .take_while(|ch| {
498            let cw = char_width(*ch);
499            if w + cw <= truncate_at {
500                w += cw;
501                true
502            } else {
503                false
504            }
505        })
506        .collect();
507    format!("{}...", truncated)
508}
509
510/// Renders the status bar and prompt/minibuffer
511pub struct StatusBarRenderer;
512
513impl StatusBarRenderer {
514    /// Render only the status bar (without prompt).
515    ///
516    /// Returns layout information with positions of clickable indicators.
517    pub fn render_status_bar(
518        frame: &mut Frame,
519        area: Rect,
520        ctx: &mut StatusBarContext<'_>,
521        config: &StatusBarConfig,
522    ) -> StatusBarLayout {
523        Self::render_status(frame, area, ctx, config)
524    }
525
526    /// Render the prompt/minibuffer
527    pub fn render_prompt(
528        frame: &mut Frame,
529        area: Rect,
530        prompt: &Prompt,
531        theme: &crate::view::theme::Theme,
532    ) {
533        let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
534
535        // Create spans for the prompt
536        let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
537
538        // If there's a selection, split the input into parts
539        if let Some((sel_start, sel_end)) = prompt.selection_range() {
540            let input = &prompt.input;
541
542            // Text before selection
543            if sel_start > 0 {
544                spans.push(Span::styled(input[..sel_start].to_string(), base_style));
545            }
546
547            // Selected text (blue background for visibility, cursor remains visible)
548            if sel_start < sel_end {
549                // Use theme colors for selection to ensure consistency across themes
550                let selection_style = Style::default()
551                    .fg(theme.prompt_selection_fg)
552                    .bg(theme.prompt_selection_bg);
553                spans.push(Span::styled(
554                    input[sel_start..sel_end].to_string(),
555                    selection_style,
556                ));
557            }
558
559            // Text after selection
560            if sel_end < input.len() {
561                spans.push(Span::styled(input[sel_end..].to_string(), base_style));
562            }
563        } else {
564            // No selection, render entire input normally
565            spans.push(Span::styled(prompt.input.clone(), base_style));
566        }
567
568        let line = Line::from(spans);
569        let prompt_line = Paragraph::new(line).style(base_style);
570
571        frame.render_widget(prompt_line, area);
572
573        // Set cursor position in the prompt
574        // Use display width (not byte length) for proper handling of:
575        // - Double-width CJK characters
576        // - Zero-width combining characters (Thai diacritics, etc.)
577        let message_width = str_width(&prompt.message);
578        let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
579        let cursor_x = (message_width + input_width_before_cursor) as u16;
580        if cursor_x < area.width {
581            frame.set_cursor_position((area.x + cursor_x, area.y));
582        }
583    }
584
585    /// Render the file open prompt with colorized path
586    /// Shows: "Open: /path/to/current/dir/filename" where the directory part is dimmed
587    /// Long paths are truncated: "/private/[...]/project/" with [...] styled differently
588    pub fn render_file_open_prompt(
589        frame: &mut Frame,
590        area: Rect,
591        prompt: &Prompt,
592        file_open_state: &crate::app::file_open::FileOpenState,
593        theme: &crate::view::theme::Theme,
594    ) {
595        let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
596        let dir_style = Style::default()
597            .fg(theme.help_separator_fg)
598            .bg(theme.prompt_bg);
599        // Style for the [...] ellipsis - use a more visible color
600        let ellipsis_style = Style::default()
601            .fg(theme.menu_highlight_fg)
602            .bg(theme.prompt_bg);
603
604        let mut spans = Vec::new();
605
606        // "Open: " prefix
607        let open_prompt = t!("file.open_prompt").to_string();
608        spans.push(Span::styled(open_prompt.clone(), base_style));
609
610        // Calculate if we need to truncate
611        // Only truncate if full path + input exceeds 90% of available width
612        let prefix_len = str_width(&open_prompt);
613        let dir_path = file_open_state.current_dir.to_string_lossy();
614        let dir_path_len = dir_path.len() + 1; // +1 for trailing slash
615        let input_len = prompt.input.len();
616        let total_len = prefix_len + dir_path_len + input_len;
617        let threshold = (area.width as usize * 90) / 100;
618
619        // Truncate the path only if total length exceeds 90% of width
620        let truncated = if total_len > threshold {
621            // Calculate how much space we have for the path after truncation
622            let available_for_path = threshold
623                .saturating_sub(prefix_len)
624                .saturating_sub(input_len);
625            truncate_path(&file_open_state.current_dir, available_for_path)
626        } else {
627            // No truncation needed - return full path
628            TruncatedPath {
629                prefix: String::new(),
630                truncated: false,
631                suffix: dir_path.to_string(),
632            }
633        };
634
635        // Build the directory display with separate spans for styling
636        if truncated.truncated {
637            // Prefix (dimmed)
638            spans.push(Span::styled(truncated.prefix.clone(), dir_style));
639            // Ellipsis "/[...]" (highlighted)
640            spans.push(Span::styled("/[...]", ellipsis_style));
641            // Suffix with trailing slash (dimmed)
642            let suffix_with_slash = if truncated.suffix.ends_with('/') {
643                truncated.suffix.clone()
644            } else {
645                format!("{}/", truncated.suffix)
646            };
647            spans.push(Span::styled(suffix_with_slash, dir_style));
648        } else {
649            // No truncation - just show the path with trailing slash
650            let path_display = if truncated.suffix.ends_with('/') {
651                truncated.suffix.clone()
652            } else {
653                format!("{}/", truncated.suffix)
654            };
655            spans.push(Span::styled(path_display, dir_style));
656        }
657
658        // User input (the filename part) - normal color
659        spans.push(Span::styled(prompt.input.clone(), base_style));
660
661        let line = Line::from(spans);
662        let prompt_line = Paragraph::new(line).style(base_style);
663
664        frame.render_widget(prompt_line, area);
665
666        // Set cursor position in the prompt
667        // Use display width for proper handling of Unicode characters
668        // We need to calculate the visual width of: "Open: " + dir_display + input[..cursor_pos]
669        let prefix_width = str_width(&open_prompt);
670        let dir_display_width = if truncated.truncated {
671            let suffix_with_slash = if truncated.suffix.ends_with('/') {
672                &truncated.suffix
673            } else {
674                // We already added "/" in the suffix_with_slash above, so approximate
675                &truncated.suffix
676            };
677            str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
678        } else {
679            str_width(&truncated.suffix) + 1 // +1 for trailing slash
680        };
681        let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
682        let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
683        if cursor_x < area.width {
684            frame.set_cursor_position((area.x + cursor_x, area.y));
685        }
686    }
687
688    /// Render a single element to its text representation.
689    /// Returns None if the element has nothing to display.
690    fn render_element(
691        element: &StatusBarElement,
692        ctx: &mut StatusBarContext<'_>,
693    ) -> Option<RenderedElement> {
694        match element {
695            StatusBarElement::Filename => {
696                let modified = if ctx.state.buffer.is_modified() {
697                    " [+]"
698                } else {
699                    ""
700                };
701                let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
702                let remote_disconnected = ctx
703                    .remote_connection
704                    .map(|conn| conn.contains("(Disconnected)"))
705                    .unwrap_or(false);
706                // The `[Container:<id>] ` / `<SSH_PREFIX>conn<...>`
707                // prefix is redundant when the dedicated `{remote}`
708                // indicator is on the bar — same identity, two
709                // places. Skip it then. When `{remote}` is NOT on
710                // the bar, keep the prefix so users still see the
711                // connection at a glance from the filename.
712                let remote_prefix = if ctx.remote_indicator_on_bar {
713                    String::new()
714                } else {
715                    ctx.remote_connection
716                        .map(|conn| {
717                            if conn.starts_with("Container:") {
718                                format!("[{}] ", conn)
719                            } else {
720                                format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}")
721                            }
722                        })
723                        .unwrap_or_default()
724                };
725                let session_prefix = ctx
726                    .session_name
727                    .map(|name| format!("[{}] ", name))
728                    .unwrap_or_default();
729                let display_name = ctx.display_name;
730                let text = format!(
731                    "{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
732                );
733                let kind = if remote_disconnected {
734                    ElementKind::RemoteDisconnected
735                } else {
736                    ElementKind::Normal
737                };
738                Some(RenderedElement { text, kind })
739            }
740            StatusBarElement::Cursor => {
741                if !ctx.state.show_cursors {
742                    return None;
743                }
744                let cursor = *ctx.cursors.primary();
745                let byte_offset_mode = ctx.state.buffer.line_count().is_none();
746                let text = if byte_offset_mode {
747                    format!("Byte {}", cursor.position)
748                } else {
749                    let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
750                    let line_start = cursor_iter.current_position();
751                    let col = cursor.position.saturating_sub(line_start);
752                    let line = ctx.state.primary_cursor_line_number.value();
753                    format!("Ln {}, Col {}", line + 1, col + 1)
754                };
755                Some(RenderedElement {
756                    text,
757                    kind: ElementKind::Normal,
758                })
759            }
760            StatusBarElement::CursorCompact => {
761                if !ctx.state.show_cursors {
762                    return None;
763                }
764                let cursor = *ctx.cursors.primary();
765                let byte_offset_mode = ctx.state.buffer.line_count().is_none();
766                let text = if byte_offset_mode {
767                    format!("{}", cursor.position)
768                } else {
769                    let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
770                    let line_start = cursor_iter.current_position();
771                    let col = cursor.position.saturating_sub(line_start);
772                    let line = ctx.state.primary_cursor_line_number.value();
773                    format!("{}:{}", line + 1, col + 1)
774                };
775                Some(RenderedElement {
776                    text,
777                    kind: ElementKind::Normal,
778                })
779            }
780            StatusBarElement::Diagnostics => {
781                let diagnostics = ctx.state.overlays.all();
782                let mut error_count = 0usize;
783                let mut warning_count = 0usize;
784                let mut info_count = 0usize;
785                let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
786                for overlay in diagnostics {
787                    if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
788                        match overlay.priority {
789                            100 => error_count += 1,
790                            50 => warning_count += 1,
791                            _ => info_count += 1,
792                        }
793                    }
794                }
795                if error_count + warning_count + info_count == 0 {
796                    return None;
797                }
798                let mut parts = Vec::new();
799                if error_count > 0 {
800                    parts.push(format!("E:{}", error_count));
801                }
802                if warning_count > 0 {
803                    parts.push(format!("W:{}", warning_count));
804                }
805                if info_count > 0 {
806                    parts.push(format!("I:{}", info_count));
807                }
808                Some(RenderedElement {
809                    text: parts.join(" "),
810                    kind: ElementKind::Normal,
811                })
812            }
813            StatusBarElement::CursorCount => {
814                if ctx.cursors.count() <= 1 {
815                    return None;
816                }
817                Some(RenderedElement {
818                    text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
819                    kind: ElementKind::Normal,
820                })
821            }
822            StatusBarElement::Messages => {
823                let mut parts: Vec<&str> = Vec::new();
824                if let Some(msg) = ctx.status_message {
825                    if !msg.is_empty() {
826                        parts.push(msg);
827                    }
828                }
829                if let Some(msg) = ctx.plugin_status_message {
830                    if !msg.is_empty() {
831                        parts.push(msg);
832                    }
833                }
834                if parts.is_empty() {
835                    return None;
836                }
837                Some(RenderedElement {
838                    text: parts.join(" | "),
839                    kind: ElementKind::Messages,
840                })
841            }
842            StatusBarElement::Chord => {
843                if ctx.chord_state.is_empty() {
844                    return None;
845                }
846                let chord_str = ctx
847                    .chord_state
848                    .iter()
849                    .map(|(code, modifiers)| {
850                        crate::input::keybindings::format_keybinding(code, modifiers)
851                    })
852                    .collect::<Vec<_>>()
853                    .join(" ");
854                Some(RenderedElement {
855                    text: format!("[{}]", chord_str),
856                    kind: ElementKind::Normal,
857                })
858            }
859            StatusBarElement::LineEnding => Some(RenderedElement {
860                text: format!(" {} ", ctx.state.buffer.line_ending().display_name()),
861                kind: ElementKind::LineEnding,
862            }),
863            StatusBarElement::Encoding => Some(RenderedElement {
864                text: format!(" {} ", ctx.state.buffer.encoding().display_name()),
865                kind: ElementKind::Encoding,
866            }),
867            StatusBarElement::Language => {
868                let text = if ctx.state.language == "text"
869                    && ctx.state.display_name != "Text"
870                    && ctx.state.display_name != "Plain Text"
871                    && ctx.state.display_name != "text"
872                {
873                    format!(" {} [syntax only] ", &ctx.state.display_name)
874                } else {
875                    format!(" {} ", &ctx.state.display_name)
876                };
877                Some(RenderedElement {
878                    text,
879                    kind: ElementKind::Language,
880                })
881            }
882            StatusBarElement::Lsp => {
883                if ctx.lsp_status.is_empty() {
884                    return None;
885                }
886                Some(RenderedElement {
887                    text: format!(" {} ", ctx.lsp_status),
888                    kind: ElementKind::Lsp,
889                })
890            }
891            StatusBarElement::Warnings => {
892                if ctx.general_warning_count == 0 {
893                    return None;
894                }
895                Some(RenderedElement {
896                    text: format!(" [\u{26a0} {}] ", ctx.general_warning_count),
897                    kind: ElementKind::WarningBadge,
898                })
899            }
900            StatusBarElement::Update => {
901                let version = ctx.update_available?;
902                Some(RenderedElement {
903                    text: format!(" {} ", t!("status.update_available", version = version)),
904                    kind: ElementKind::Update,
905                })
906            }
907            StatusBarElement::Palette => {
908                let shortcut = ctx
909                    .keybindings
910                    .get_keybinding_for_action(
911                        &crate::input::keybindings::Action::QuickOpen,
912                        crate::input::keybindings::KeyContext::Global,
913                    )
914                    .unwrap_or_else(|| "?".to_string());
915                Some(RenderedElement {
916                    text: format!(" {} ", t!("status.palette", shortcut = shortcut)),
917                    kind: ElementKind::Palette,
918                })
919            }
920            StatusBarElement::Clock => {
921                let now = chrono::Local::now();
922                let text = format!("{:02}:{:02}", now.hour(), now.minute());
923                Some(RenderedElement {
924                    text,
925                    kind: ElementKind::Clock,
926                })
927            }
928            StatusBarElement::RemoteIndicator => {
929                // Persistent remote-authority entry point. When local we
930                // still emit a short label so the indicator is visible —
931                // the spec calls for a persistent control, not one that
932                // vanishes when there is nothing to report.
933                //
934                // Precedence: plugin-supplied override (via
935                // `SetRemoteIndicatorState`) wins over the authority-
936                // derived state. The override carries its own label;
937                // derived states synthesize one from `remote_connection`.
938                let (text, state) = if let Some(over) = ctx.remote_state_override {
939                    (format!(" {} ", over.label()), over.state())
940                } else {
941                    match ctx.remote_connection {
942                        None => (" Local ".to_string(), RemoteIndicatorState::Local),
943                        Some(conn) if conn.contains("(Disconnected)") => {
944                            (format!(" {} ", conn), RemoteIndicatorState::Disconnected)
945                        }
946                        Some(conn) => (format!(" {} ", conn), RemoteIndicatorState::Connected),
947                    }
948                };
949                Some(RenderedElement {
950                    text,
951                    kind: ElementKind::RemoteIndicator(state),
952                })
953            }
954        }
955    }
956
957    /// Get the style for a rendered element based on its kind, theme, and hover state.
958    fn element_style(
959        kind: ElementKind,
960        theme: &crate::view::theme::Theme,
961        hover: StatusBarHover,
962        _warning_level: WarningLevel,
963        lsp_state: LspIndicatorState,
964    ) -> Style {
965        match kind {
966            ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
967                .fg(theme.status_bar_fg)
968                .bg(theme.status_bar_bg),
969            ElementKind::RemoteDisconnected => Style::default()
970                .fg(theme.status_error_indicator_fg)
971                .bg(theme.status_error_indicator_bg),
972            ElementKind::LineEnding => {
973                let is_hovering = hover == StatusBarHover::LineEndingIndicator;
974                let (fg, bg) = if is_hovering {
975                    (theme.menu_hover_fg, theme.menu_hover_bg)
976                } else {
977                    (theme.status_bar_fg, theme.status_bar_bg)
978                };
979                let mut style = Style::default().fg(fg).bg(bg);
980                if is_hovering {
981                    style = style.add_modifier(Modifier::UNDERLINED);
982                }
983                style
984            }
985            ElementKind::Encoding => {
986                let is_hovering = hover == StatusBarHover::EncodingIndicator;
987                let (fg, bg) = if is_hovering {
988                    (theme.menu_hover_fg, theme.menu_hover_bg)
989                } else {
990                    (theme.status_bar_fg, theme.status_bar_bg)
991                };
992                let mut style = Style::default().fg(fg).bg(bg);
993                if is_hovering {
994                    style = style.add_modifier(Modifier::UNDERLINED);
995                }
996                style
997            }
998            ElementKind::Language => {
999                let is_hovering = hover == StatusBarHover::LanguageIndicator;
1000                let (fg, bg) = if is_hovering {
1001                    (theme.menu_hover_fg, theme.menu_hover_bg)
1002                } else {
1003                    (theme.status_bar_fg, theme.status_bar_bg)
1004                };
1005                let mut style = Style::default().fg(fg).bg(bg);
1006                if is_hovering {
1007                    style = style.add_modifier(Modifier::UNDERLINED);
1008                }
1009                style
1010            }
1011            ElementKind::Lsp => {
1012                let is_hovering = hover == StatusBarHover::LspIndicator;
1013                // Color by LSP state, reusing the diagnostic theme keys:
1014                //   Error → diagnostics.error_*   (red-ish)
1015                //   Off   → diagnostics.warning_* (yellow-ish)
1016                //   On    → diagnostics.info_*    (info-ish)
1017                //   None  → default status-bar colors
1018                let (fg, bg) = match lsp_state {
1019                    LspIndicatorState::Error => {
1020                        (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
1021                    }
1022                    LspIndicatorState::Off => {
1023                        (theme.diagnostic_warning_fg, theme.diagnostic_warning_bg)
1024                    }
1025                    LspIndicatorState::On => (theme.diagnostic_info_fg, theme.diagnostic_info_bg),
1026                    // Dismissed: fall back to the neutral status-bar
1027                    // palette so the pill reads as low-priority.  We
1028                    // intentionally don't introduce a dedicated theme
1029                    // key — every theme already carries the plain
1030                    // status-bar fg/bg, which reliably produces a
1031                    // "muted" look next to the vivid error/warning/info
1032                    // palettes above.
1033                    LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
1034                    LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
1035                };
1036                let mut style = Style::default().fg(fg).bg(bg);
1037                // Always underline on hover — the indicator is clickable
1038                // in all non-empty states.  Previously we only underlined
1039                // when warning_level != None, so "LSP (on)" gave no hover
1040                // cue that it was clickable.
1041                if is_hovering && lsp_state != LspIndicatorState::None {
1042                    style = style.add_modifier(Modifier::UNDERLINED);
1043                }
1044                style
1045            }
1046            ElementKind::WarningBadge => {
1047                let is_hovering = hover == StatusBarHover::WarningBadge;
1048                let (fg, bg) = if is_hovering {
1049                    (
1050                        theme.status_warning_indicator_hover_fg,
1051                        theme.status_warning_indicator_hover_bg,
1052                    )
1053                } else {
1054                    (
1055                        theme.status_warning_indicator_fg,
1056                        theme.status_warning_indicator_bg,
1057                    )
1058                };
1059                let mut style = Style::default().fg(fg).bg(bg);
1060                if is_hovering {
1061                    style = style.add_modifier(Modifier::UNDERLINED);
1062                }
1063                style
1064            }
1065            ElementKind::Update => Style::default()
1066                .fg(theme.menu_highlight_fg)
1067                .bg(theme.menu_dropdown_bg),
1068            ElementKind::Palette => Style::default()
1069                .fg(theme.help_indicator_fg)
1070                .bg(theme.help_indicator_bg),
1071            ElementKind::RemoteIndicator(state) => {
1072                let is_hovering = hover == StatusBarHover::RemoteIndicator;
1073                let (fg, bg) = match state {
1074                    // Connecting and Connected share the "help
1075                    // indicator" palette so the transition from one to
1076                    // the other is a glyph swap rather than a color
1077                    // flash — the user's eye tracks the indicator
1078                    // changing, not disappearing.
1079                    RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1080                        (theme.help_indicator_fg, theme.help_indicator_bg)
1081                    }
1082                    // FailedAttach + Disconnected share the error
1083                    // palette. Both are "the remote isn't reaching you
1084                    // right now" states, differing only in cause.
1085                    RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1086                        theme.status_error_indicator_fg,
1087                        theme.status_error_indicator_bg,
1088                    ),
1089                    // Local: neutral status-bar palette.
1090                    RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
1091                };
1092                let mut style = Style::default().fg(fg).bg(bg);
1093                if is_hovering {
1094                    style = style.add_modifier(Modifier::UNDERLINED);
1095                }
1096                style
1097            }
1098        }
1099    }
1100
1101    /// Map an ElementKind to the layout field it should populate.
1102    fn update_layout_for_element(
1103        layout: &mut StatusBarLayout,
1104        kind: ElementKind,
1105        row: u16,
1106        start_col: u16,
1107        end_col: u16,
1108    ) {
1109        match kind {
1110            ElementKind::LineEnding => {
1111                layout.line_ending_indicator = Some((row, start_col, end_col))
1112            }
1113            ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
1114            ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
1115            ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
1116            ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
1117            ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
1118            ElementKind::RemoteIndicator(_) => {
1119                layout.remote_indicator = Some((row, start_col, end_col))
1120            }
1121            _ => {}
1122        }
1123    }
1124
1125    /// Build the styled spans for a single rendered element, honoring the
1126    /// special-case two-color rendering for a disconnected remote filename.
1127    ///
1128    /// Returns the spans and the total display width of the emitted text.
1129    fn element_spans(
1130        rendered: &RenderedElement,
1131        theme: &crate::view::theme::Theme,
1132        hover: StatusBarHover,
1133        warning_level: WarningLevel,
1134        lsp_state: LspIndicatorState,
1135    ) -> (Vec<Span<'static>>, usize) {
1136        let base_style = Style::default()
1137            .fg(theme.status_bar_fg)
1138            .bg(theme.status_bar_bg);
1139        let width = str_width(&rendered.text);
1140
1141        if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
1142        {
1143            let error_style = Style::default()
1144                .fg(theme.status_error_indicator_fg)
1145                .bg(theme.status_error_indicator_bg);
1146            if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
1147                let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
1148                let prefix = rendered.text[..split_at].to_string();
1149                let rest = rendered.text[split_at..].to_string();
1150                return (
1151                    vec![
1152                        Span::styled(prefix, error_style),
1153                        Span::styled(rest, base_style),
1154                    ],
1155                    width,
1156                );
1157            }
1158            return (
1159                vec![Span::styled(rendered.text.clone(), error_style)],
1160                width,
1161            );
1162        }
1163
1164        let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
1165        let spans = if rendered.kind == ElementKind::Clock {
1166            // "HH:MM" — blink the colon via terminal hardware (SGR 5)
1167            vec![
1168                Span::styled(rendered.text[..2].to_string(), style),
1169                Span::styled(":".to_string(), style.add_modifier(Modifier::SLOW_BLINK)),
1170                Span::styled(rendered.text[3..].to_string(), style),
1171            ]
1172        } else {
1173            vec![Span::styled(rendered.text.clone(), style)]
1174        };
1175        (spans, width)
1176    }
1177
1178    /// Render a configured side (left/right) into styled per-element groups.
1179    fn render_side(
1180        config_side: &[StatusBarElement],
1181        ctx: &mut StatusBarContext<'_>,
1182    ) -> Vec<(Vec<Span<'static>>, usize, ElementKind)> {
1183        let rendered: Vec<RenderedElement> = config_side
1184            .iter()
1185            .filter_map(|elem| Self::render_element(elem, ctx))
1186            .filter(|e| !e.text.is_empty())
1187            .collect();
1188
1189        let theme = ctx.theme;
1190        let hover = ctx.hover;
1191        let warning_level = ctx.warning_level;
1192        let lsp_state = ctx.lsp_indicator_state;
1193        rendered
1194            .into_iter()
1195            .map(|r| {
1196                let kind = r.kind;
1197                let (spans, width) =
1198                    Self::element_spans(&r, theme, hover, warning_level, lsp_state);
1199                (spans, width, kind)
1200            })
1201            .collect()
1202    }
1203
1204    /// Render the normal status bar (config-driven).
1205    fn render_status(
1206        frame: &mut Frame,
1207        area: Rect,
1208        ctx: &mut StatusBarContext<'_>,
1209        config: &StatusBarConfig,
1210    ) -> StatusBarLayout {
1211        let mut layout = StatusBarLayout::default();
1212        let base_style = Style::default()
1213            .fg(ctx.theme.status_bar_fg)
1214            .bg(ctx.theme.status_bar_bg);
1215        let available_width = area.width as usize;
1216
1217        if available_width == 0 || area.height == 0 {
1218            return layout;
1219        }
1220
1221        // Tell the per-element renderer whether the dedicated
1222        // RemoteIndicator is on the bar so the Filename branch
1223        // can drop its now-redundant `[Container:<id>] ` /
1224        // SSH prefix.
1225        ctx.remote_indicator_on_bar = config
1226            .left
1227            .iter()
1228            .chain(config.right.iter())
1229            .any(|e| matches!(e, StatusBarElement::RemoteIndicator));
1230
1231        let left_items = Self::render_side(&config.left, ctx);
1232        let mut right_items = Self::render_side(&config.right, ctx);
1233
1234        const SEPARATOR: &str = " | ";
1235        let separator_width = str_width(SEPARATOR);
1236
1237        // Reserve a sane minimum for the left side so the buffer name and
1238        // cursor position aren't truncated to a single character on narrow
1239        // terminals (regression originally reported as
1240        // `t  LF  ASCII  Markdown ...`).  Drop low-priority right elements
1241        // (configured right-most first) until the remaining right side fits
1242        // alongside that minimum left budget.  We never drop the *first*
1243        // right element so the user keeps at least one piece of right-side
1244        // status if any was configured.
1245        let total_right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1246        let left_min_target = available_width
1247            .saturating_mul(2)
1248            .saturating_div(5) // ~40% of width reserved for left when feasible
1249            .min(40); // but never demand more than 40 cols even on wide terminals
1250        let right_budget = available_width.saturating_sub(left_min_target + 1);
1251        if total_right_width > right_budget && right_items.len() > 1 {
1252            let mut current = total_right_width;
1253            while current > right_budget && right_items.len() > 1 {
1254                if let Some(dropped) = right_items.pop() {
1255                    current = current.saturating_sub(dropped.1);
1256                } else {
1257                    break;
1258                }
1259            }
1260        }
1261
1262        let right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1263
1264        let narrow = available_width < 15;
1265        let left_max_width = if narrow {
1266            available_width
1267        } else if available_width > right_width + 1 {
1268            available_width - right_width - 1
1269        } else {
1270            1
1271        };
1272
1273        // Emit left side, consuming `left_items` so each element's spans move
1274        // directly into the output without a clone. Widths are cached so the
1275        // truncation check doesn't re-measure text.
1276        let mut spans: Vec<Span<'static>> = Vec::new();
1277        let mut used_left: usize = 0;
1278
1279        for (idx, (item_spans, width, kind)) in left_items.into_iter().enumerate() {
1280            let sep_width = if idx == 0 { 0 } else { separator_width };
1281            if used_left + sep_width >= left_max_width {
1282                break;
1283            }
1284            if sep_width > 0 {
1285                spans.push(Span::styled(SEPARATOR, base_style));
1286                used_left += sep_width;
1287            }
1288
1289            let remaining = left_max_width - used_left;
1290            let start_col = used_left;
1291
1292            if width <= remaining {
1293                spans.extend(item_spans);
1294                used_left += width;
1295
1296                Self::update_layout_for_element(
1297                    &mut layout,
1298                    kind,
1299                    area.y,
1300                    area.x + start_col as u16,
1301                    area.x + (start_col + width) as u16,
1302                );
1303            } else {
1304                // Overflow: truncate the concatenated text of this element.
1305                // Per-span styling is lost for the overflowed slice — we fall
1306                // back to whatever `element_style` would have returned.
1307                let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1308                let truncated = truncate_to_width(&group_text, remaining);
1309                let truncated_width = str_width(&truncated);
1310                let overflow_style = Self::element_style(
1311                    kind,
1312                    ctx.theme,
1313                    ctx.hover,
1314                    ctx.warning_level,
1315                    ctx.lsp_indicator_state,
1316                );
1317                spans.push(Span::styled(truncated, overflow_style));
1318                used_left += truncated_width;
1319
1320                Self::update_layout_for_element(
1321                    &mut layout,
1322                    kind,
1323                    area.y,
1324                    area.x + start_col as u16,
1325                    area.x + (start_col + truncated_width) as u16,
1326                );
1327                break;
1328            }
1329        }
1330
1331        if narrow {
1332            if used_left < available_width {
1333                spans.push(Span::styled(
1334                    " ".repeat(available_width - used_left),
1335                    base_style,
1336                ));
1337            }
1338            frame.render_widget(Paragraph::new(Line::from(spans)), area);
1339            return layout;
1340        }
1341
1342        let mut col_offset = used_left;
1343        if col_offset + right_width < available_width {
1344            let padding = available_width - col_offset - right_width;
1345            spans.push(Span::styled(" ".repeat(padding), base_style));
1346            col_offset = available_width - right_width;
1347        } else if col_offset < available_width {
1348            spans.push(Span::styled(" ", base_style));
1349            col_offset += 1;
1350        }
1351
1352        let mut current_col = area.x + col_offset as u16;
1353        for (item_spans, width, kind) in right_items {
1354            Self::update_layout_for_element(
1355                &mut layout,
1356                kind,
1357                area.y,
1358                current_col,
1359                current_col + width as u16,
1360            );
1361            spans.extend(item_spans);
1362            current_col += width as u16;
1363        }
1364
1365        frame.render_widget(Paragraph::new(Line::from(spans)), area);
1366        layout
1367    }
1368
1369    /// Render the search options bar (shown when search prompt is active)
1370    ///
1371    /// Displays checkboxes for search options with their keyboard shortcuts:
1372    /// - Case Sensitive (Alt+C)
1373    /// - Whole Word (Alt+W)
1374    /// - Regex (Alt+R)
1375    /// - Confirm Each (Alt+I) - only shown in replace mode
1376    ///
1377    /// # Returns
1378    /// Layout information for hit testing mouse clicks on checkboxes
1379    #[allow(clippy::too_many_arguments)]
1380    pub fn render_search_options(
1381        frame: &mut Frame,
1382        area: Rect,
1383        case_sensitive: bool,
1384        whole_word: bool,
1385        use_regex: bool,
1386        confirm_each: Option<bool>, // None = don't show, Some(value) = show with this state
1387        theme: &crate::view::theme::Theme,
1388        keybindings: &crate::input::keybindings::KeybindingResolver,
1389        hover: SearchOptionsHover,
1390    ) -> SearchOptionsLayout {
1391        use crate::primitives::display_width::str_width;
1392
1393        let mut layout = SearchOptionsLayout {
1394            row: area.y,
1395            ..Default::default()
1396        };
1397
1398        // Use menu dropdown background (dark gray) for the options bar
1399        let base_style = Style::default()
1400            .fg(theme.menu_dropdown_fg)
1401            .bg(theme.menu_dropdown_bg);
1402
1403        // Style for hovered options - use menu hover colors
1404        let hover_style = Style::default()
1405            .fg(theme.menu_hover_fg)
1406            .bg(theme.menu_hover_bg);
1407
1408        // Helper to look up keybinding for an action (Prompt context first, then Global)
1409        let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1410            keybindings
1411                .get_keybinding_for_action(action, crate::input::keybindings::KeyContext::Prompt)
1412                .or_else(|| {
1413                    keybindings.get_keybinding_for_action(
1414                        action,
1415                        crate::input::keybindings::KeyContext::Global,
1416                    )
1417                })
1418        };
1419
1420        // Get keybindings for search options
1421        let case_shortcut =
1422            get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1423        let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1424        let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1425
1426        // Build the options display with checkboxes
1427        let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1428        let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1429        let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1430
1431        // Style for active (checked) options - highlighted with menu highlight colors
1432        let active_style = Style::default()
1433            .fg(theme.menu_highlight_fg)
1434            .bg(theme.menu_dropdown_bg);
1435
1436        // Style for keyboard shortcuts - use theme color for consistency
1437        let shortcut_style = Style::default()
1438            .fg(theme.help_separator_fg)
1439            .bg(theme.menu_dropdown_bg);
1440
1441        // Hovered shortcut style
1442        let hover_shortcut_style = Style::default()
1443            .fg(theme.menu_hover_fg)
1444            .bg(theme.menu_hover_bg);
1445
1446        let mut spans = Vec::new();
1447        let mut current_col = area.x;
1448
1449        // Left padding
1450        spans.push(Span::styled(" ", base_style));
1451        current_col += 1;
1452
1453        // Helper to get style based on hover and checked state
1454        let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
1455            if is_hovered {
1456                hover_style
1457            } else if is_checked {
1458                active_style
1459            } else {
1460                base_style
1461            }
1462        };
1463
1464        // Case Sensitive option
1465        let case_hovered = hover == SearchOptionsHover::CaseSensitive;
1466        let case_start = current_col;
1467        let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
1468        let case_shortcut_text = case_shortcut
1469            .as_ref()
1470            .map(|s| format!(" ({})", s))
1471            .unwrap_or_default();
1472        let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
1473
1474        spans.push(Span::styled(
1475            case_label,
1476            get_checkbox_style(case_hovered, case_sensitive),
1477        ));
1478        if !case_shortcut_text.is_empty() {
1479            spans.push(Span::styled(
1480                case_shortcut_text,
1481                if case_hovered {
1482                    hover_shortcut_style
1483                } else {
1484                    shortcut_style
1485                },
1486            ));
1487        }
1488        current_col += case_full_width as u16;
1489        layout.case_sensitive = Some((case_start, current_col));
1490
1491        // Separator
1492        spans.push(Span::styled("   ", base_style));
1493        current_col += 3;
1494
1495        // Whole Word option
1496        let word_hovered = hover == SearchOptionsHover::WholeWord;
1497        let word_start = current_col;
1498        let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
1499        let word_shortcut_text = word_shortcut
1500            .as_ref()
1501            .map(|s| format!(" ({})", s))
1502            .unwrap_or_default();
1503        let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
1504
1505        spans.push(Span::styled(
1506            word_label,
1507            get_checkbox_style(word_hovered, whole_word),
1508        ));
1509        if !word_shortcut_text.is_empty() {
1510            spans.push(Span::styled(
1511                word_shortcut_text,
1512                if word_hovered {
1513                    hover_shortcut_style
1514                } else {
1515                    shortcut_style
1516                },
1517            ));
1518        }
1519        current_col += word_full_width as u16;
1520        layout.whole_word = Some((word_start, current_col));
1521
1522        // Separator
1523        spans.push(Span::styled("   ", base_style));
1524        current_col += 3;
1525
1526        // Regex option
1527        let regex_hovered = hover == SearchOptionsHover::Regex;
1528        let regex_start = current_col;
1529        let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
1530        let regex_shortcut_text = regex_shortcut
1531            .as_ref()
1532            .map(|s| format!(" ({})", s))
1533            .unwrap_or_default();
1534        let regex_full_width = str_width(&regex_label) + str_width(&regex_shortcut_text);
1535
1536        spans.push(Span::styled(
1537            regex_label,
1538            get_checkbox_style(regex_hovered, use_regex),
1539        ));
1540        if !regex_shortcut_text.is_empty() {
1541            spans.push(Span::styled(
1542                regex_shortcut_text,
1543                if regex_hovered {
1544                    hover_shortcut_style
1545                } else {
1546                    shortcut_style
1547                },
1548            ));
1549        }
1550        current_col += regex_full_width as u16;
1551        layout.regex = Some((regex_start, current_col));
1552
1553        // Show capture group hint when regex is enabled in replace mode
1554        if use_regex && confirm_each.is_some() {
1555            let hint = " \u{2502} $1,$2,…";
1556            spans.push(Span::styled(hint, shortcut_style));
1557            current_col += str_width(hint) as u16;
1558        }
1559
1560        // Confirm Each option (only shown in replace mode)
1561        if let Some(confirm_value) = confirm_each {
1562            let confirm_shortcut =
1563                get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
1564            let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
1565
1566            // Separator
1567            spans.push(Span::styled("   ", base_style));
1568            current_col += 3;
1569
1570            let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
1571            let confirm_start = current_col;
1572            let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
1573            let confirm_shortcut_text = confirm_shortcut
1574                .as_ref()
1575                .map(|s| format!(" ({})", s))
1576                .unwrap_or_default();
1577            let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
1578
1579            spans.push(Span::styled(
1580                confirm_label,
1581                get_checkbox_style(confirm_hovered, confirm_value),
1582            ));
1583            if !confirm_shortcut_text.is_empty() {
1584                spans.push(Span::styled(
1585                    confirm_shortcut_text,
1586                    if confirm_hovered {
1587                        hover_shortcut_style
1588                    } else {
1589                        shortcut_style
1590                    },
1591                ));
1592            }
1593            current_col += confirm_full_width as u16;
1594            layout.confirm_each = Some((confirm_start, current_col));
1595        }
1596
1597        // Fill remaining space
1598        let current_width = (current_col - area.x) as usize;
1599        let available_width = area.width as usize;
1600        if current_width < available_width {
1601            spans.push(Span::styled(
1602                " ".repeat(available_width.saturating_sub(current_width)),
1603                base_style,
1604            ));
1605        }
1606
1607        let options_line = Paragraph::new(Line::from(spans));
1608        frame.render_widget(options_line, area);
1609
1610        layout
1611    }
1612}
1613
1614#[cfg(test)]
1615mod tests {
1616    use super::*;
1617    use std::path::PathBuf;
1618
1619    #[test]
1620    fn test_truncate_path_short_path() {
1621        let path = PathBuf::from("/home/user/project");
1622        let result = truncate_path(&path, 50);
1623
1624        assert!(!result.truncated);
1625        assert_eq!(result.suffix, "/home/user/project");
1626        assert!(result.prefix.is_empty());
1627    }
1628
1629    #[test]
1630    fn test_truncate_path_long_path() {
1631        let path = PathBuf::from(
1632            "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
1633        );
1634        let result = truncate_path(&path, 40);
1635
1636        assert!(result.truncated, "Path should be truncated");
1637        assert_eq!(result.prefix, "/private");
1638        assert!(
1639            result.suffix.contains("project_root"),
1640            "Suffix should contain project_root"
1641        );
1642    }
1643
1644    #[test]
1645    fn test_truncate_path_preserves_last_components() {
1646        let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
1647        let result = truncate_path(&path, 30);
1648
1649        assert!(result.truncated);
1650        // Should preserve the last components that fit
1651        assert!(
1652            result.suffix.contains("src"),
1653            "Should preserve last component 'src', got: {}",
1654            result.suffix
1655        );
1656    }
1657
1658    #[test]
1659    fn test_truncate_path_display_len() {
1660        let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
1661        let result = truncate_path(&path, 30);
1662
1663        // The display length should not exceed max_len (approximately)
1664        let display = result.to_string_plain();
1665        assert!(
1666            display.len() <= 35, // Allow some slack for trailing slash
1667            "Display should be truncated to around 30 chars, got {} chars: {}",
1668            display.len(),
1669            display
1670        );
1671    }
1672
1673    #[test]
1674    fn test_truncate_path_root_only() {
1675        let path = PathBuf::from("/");
1676        let result = truncate_path(&path, 50);
1677
1678        assert!(!result.truncated);
1679        assert_eq!(result.suffix, "/");
1680    }
1681
1682    #[test]
1683    fn test_truncate_path_multibyte_single_component_does_not_panic() {
1684        // Routes into the "truncate the end" branch (line 414): the prefix
1685        // alone exceeds max_len, so available_for_suffix becomes 0. Before
1686        // the fix, byte-slicing `path_str` at `max_len - 3 = 2` lands
1687        // inside the 3-byte UTF-8 sequence for `ユ` and panicked the
1688        // editor — same class as #1718.
1689        let path = PathBuf::from("/ユーザーのプロジェクト名前/file");
1690        let result = truncate_path(&path, 5);
1691        let display = result.to_string_plain();
1692        assert!(display.is_char_boundary(display.len()));
1693        assert!(display.ends_with("..."));
1694    }
1695
1696    #[test]
1697    fn test_truncate_path_multibyte_last_component_does_not_panic() {
1698        // Routes into the "truncate the last component" branch (line 453):
1699        // available_for_suffix is large enough to enter the suffix-build
1700        // loop, but the only remaining component doesn't fit, so we fall
1701        // back to truncating it. Before the fix, byte-slicing the
1702        // non-ASCII component at `truncate_to = 1` lands inside the 3-byte
1703        // UTF-8 sequence for `ユ` and panicked.
1704        let path = PathBuf::from("/a/ユーザーのプロジェクト名前");
1705        let result = truncate_path(&path, 13);
1706        let display = result.to_string_plain();
1707        assert!(display.is_char_boundary(display.len()));
1708    }
1709
1710    #[test]
1711    fn test_truncated_path_to_string_plain() {
1712        let truncated = TruncatedPath {
1713            prefix: "/home".to_string(),
1714            truncated: true,
1715            suffix: "/project/src".to_string(),
1716        };
1717
1718        assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
1719    }
1720
1721    #[test]
1722    fn test_truncated_path_to_string_plain_no_truncation() {
1723        let truncated = TruncatedPath {
1724            prefix: String::new(),
1725            truncated: false,
1726            suffix: "/home/user/project".to_string(),
1727        };
1728
1729        assert_eq!(truncated.to_string_plain(), "/home/user/project");
1730    }
1731
1732    #[test]
1733    fn test_remote_indicator_element_kind_equality() {
1734        // Each lifecycle state produces a distinct ElementKind so the styler
1735        // can pick the right palette for Local / Connecting / Connected /
1736        // FailedAttach / Disconnected.
1737        assert_eq!(
1738            ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
1739            ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
1740        );
1741        let distinct = [
1742            RemoteIndicatorState::Local,
1743            RemoteIndicatorState::Connecting,
1744            RemoteIndicatorState::Connected,
1745            RemoteIndicatorState::FailedAttach,
1746            RemoteIndicatorState::Disconnected,
1747        ];
1748        for (i, a) in distinct.iter().enumerate() {
1749            for (j, b) in distinct.iter().enumerate() {
1750                if i == j {
1751                    continue;
1752                }
1753                assert_ne!(
1754                    ElementKind::RemoteIndicator(*a),
1755                    ElementKind::RemoteIndicator(*b),
1756                    "expected {:?} != {:?}",
1757                    a,
1758                    b
1759                );
1760            }
1761        }
1762    }
1763
1764    #[test]
1765    fn test_remote_indicator_state_default_is_local() {
1766        // `Default` → `Local` is relied on by callers that construct the
1767        // indicator before a connection is known.
1768        assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
1769    }
1770
1771    #[test]
1772    fn test_remote_indicator_override_deserializes_kind_tags() {
1773        // Pins the wire shape the `SetRemoteIndicatorState` plugin op
1774        // accepts. A breaking change here would silently reject plugin
1775        // payloads after upgrade.
1776        let cases: &[(&str, RemoteIndicatorOverride)] = &[
1777            (r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
1778            (
1779                r#"{"kind":"connecting","label":"Building"}"#,
1780                RemoteIndicatorOverride::Connecting {
1781                    label: Some("Building".into()),
1782                },
1783            ),
1784            (
1785                r#"{"kind":"connecting"}"#,
1786                RemoteIndicatorOverride::Connecting { label: None },
1787            ),
1788            (
1789                r#"{"kind":"connected","label":"Container:abc"}"#,
1790                RemoteIndicatorOverride::Connected {
1791                    label: Some("Container:abc".into()),
1792                },
1793            ),
1794            (
1795                r#"{"kind":"failed_attach","error":"exit 1"}"#,
1796                RemoteIndicatorOverride::FailedAttach {
1797                    error: Some("exit 1".into()),
1798                },
1799            ),
1800            (
1801                r#"{"kind":"disconnected","label":"Container:abc"}"#,
1802                RemoteIndicatorOverride::Disconnected {
1803                    label: Some("Container:abc".into()),
1804                },
1805            ),
1806        ];
1807        for (json, expected) in cases {
1808            let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
1809                .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
1810            assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
1811        }
1812    }
1813
1814    #[test]
1815    fn test_remote_indicator_override_labels() {
1816        // Labels surface in the `{remote}` element text directly, so
1817        // defaults matter — a missing `label` must still produce
1818        // something readable.
1819        let connecting = RemoteIndicatorOverride::Connecting { label: None };
1820        assert!(
1821            connecting.label().contains("Connecting"),
1822            "connecting default label should mention Connecting, got {:?}",
1823            connecting.label()
1824        );
1825
1826        let connecting_labeled = RemoteIndicatorOverride::Connecting {
1827            label: Some("Building".into()),
1828        };
1829        assert!(
1830            connecting_labeled.label().contains("Building"),
1831            "labeled connecting should include the label, got {:?}",
1832            connecting_labeled.label()
1833        );
1834
1835        let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
1836        assert_eq!(failed_bare.label(), "Attach failed");
1837
1838        let failed_detail = RemoteIndicatorOverride::FailedAttach {
1839            error: Some("exit 1".into()),
1840        };
1841        assert!(
1842            failed_detail.label().contains("exit 1"),
1843            "failed with error should include the error, got {:?}",
1844            failed_detail.label()
1845        );
1846    }
1847
1848    #[test]
1849    fn test_remote_indicator_override_state_projection() {
1850        assert_eq!(
1851            RemoteIndicatorOverride::Local.state(),
1852            RemoteIndicatorState::Local
1853        );
1854        assert_eq!(
1855            RemoteIndicatorOverride::Connecting { label: None }.state(),
1856            RemoteIndicatorState::Connecting
1857        );
1858        assert_eq!(
1859            RemoteIndicatorOverride::Connected { label: None }.state(),
1860            RemoteIndicatorState::Connected
1861        );
1862        assert_eq!(
1863            RemoteIndicatorOverride::FailedAttach { error: None }.state(),
1864            RemoteIndicatorState::FailedAttach
1865        );
1866        assert_eq!(
1867            RemoteIndicatorOverride::Disconnected { label: None }.state(),
1868            RemoteIndicatorState::Disconnected
1869        );
1870    }
1871}