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