Skip to main content

fresh/view/ui/
status_bar.rs

1//! Status bar and prompt/minibuffer rendering
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use crate::app::types::CellThemeRecorder;
7use crate::app::WarningLevel;
8use crate::config::{StatusBarConfig, StatusBarElement};
9use crate::primitives::display_width::{char_width, str_width};
10use crate::state::EditorState;
11use crate::view::prompt::Prompt;
12use chrono::Timelike;
13use ratatui::layout::Rect;
14use ratatui::style::{Modifier, Style};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::Paragraph;
17use ratatui::Frame;
18use rust_i18n::t;
19
20/// Text that both marks a buffer as "edited over a disconnected SSH session"
21/// and styles the prefix in the status bar. Kept as constants so `render_element`
22/// and `element_spans` stay in sync.
23const SSH_PREFIX: &str = "[SSH:";
24const SSH_PREFIX_TERMINATOR: &str = "] ";
25
26/// Categorization of how a rendered element should be styled and tracked for click detection.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28enum ElementKind {
29    /// Normal text using base status bar colors
30    Normal,
31    /// Line ending indicator (clickable)
32    LineEnding,
33    /// Encoding indicator (clickable)
34    Encoding,
35    /// Language indicator (clickable)
36    Language,
37    /// LSP status indicator (colored by warning level, clickable)
38    Lsp,
39    /// Warning badge (colored, clickable)
40    WarningBadge,
41    /// Update available indicator (highlighted)
42    Update,
43    /// Command palette shortcut hint (distinct style)
44    Palette,
45    /// Status message area (clickable to show history)
46    Messages,
47    /// Remote disconnected prefix (error colors)
48    RemoteDisconnected,
49    /// Clock element — colon rendered with hardware blink
50    Clock,
51    /// Remote authority indicator — styling driven by connection state
52    RemoteIndicator(RemoteIndicatorState),
53    /// Workspace-trust indicator — always present, styling driven by the
54    /// active session's trust level. Clickable (opens the trust prompt).
55    WorkspaceTrust(crate::services::workspace_trust::TrustLevel),
56    /// Custom plugin token
57    Custom,
58}
59
60/// Visual/semantic state of the remote authority indicator.
61///
62/// Covers the full dev-container UX lifecycle the spec asks for —
63/// Local, Connecting to a remote authority, Connected, FailedAttach,
64/// Disconnected — while remaining general enough for any remote
65/// authority Fresh currently supports (SSH today; containers;
66/// anything a plugin installs via `editor.setAuthority(...)`).
67///
68/// Variants deliberately hold no data — phase labels and error text
69/// are passed alongside via `StatusBarContext::remote_state_override`
70/// (added in Phase B-2) so the enum stays `Copy` and core code never
71/// learns devcontainer-specific vocabulary (see
72/// `AUTHORITY_DESIGN.md` principle 3).
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum RemoteIndicatorState {
75    /// Editing local files; rendered with the default status-bar palette.
76    #[default]
77    Local,
78    /// An attach (or reconnect) is in flight — rendered with a spinner
79    /// glyph and the help-indicator palette. Plugins drive this via
80    /// `setRemoteIndicatorState` before kicking off `devcontainer up`
81    /// or similar long-running setup.
82    Connecting,
83    /// Connected to an SSH / container / other remote authority.
84    Connected,
85    /// The last attach attempt failed. Rendered with the error palette
86    /// so the state is visible at a glance; the popup surfaces the
87    /// error detail and a Retry action.
88    FailedAttach,
89    /// Connection lost — rendered with the error palette as a persistent
90    /// warning that writes/saves are no longer reaching the authority.
91    Disconnected,
92}
93
94/// Plugin-supplied override for the Remote Indicator. Carries both
95/// the state enum and a user-visible label/error text so core doesn't
96/// need to know how to phrase "Connecting..." or a specific failure
97/// string — the plugin owns the copy.
98///
99/// Deserialized from the tagged JSON shape accepted by the
100/// `SetRemoteIndicatorState` plugin op (see `fresh-core::api`). Kept
101/// in the view crate so the enum lives next to the rendering that
102/// consumes it.
103#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
104#[serde(tag = "kind", rename_all = "snake_case")]
105pub enum RemoteIndicatorOverride {
106    /// Force the indicator to "Local" even when the authority would
107    /// otherwise read as Connected. Rarely needed in practice.
108    Local,
109    /// Attach is in flight. `label` is the short text shown next to
110    /// the spinner glyph (e.g. "Building", "Pulling image").
111    Connecting {
112        #[serde(default)]
113        label: Option<String>,
114    },
115    /// Force Connected. `label` overrides the authority's display
116    /// string if present; otherwise the derived label is shown.
117    Connected {
118        #[serde(default)]
119        label: Option<String>,
120    },
121    /// Last attach attempt failed. `error` is the short message the
122    /// indicator renders; longer context belongs in the popup.
123    FailedAttach {
124        #[serde(default)]
125        error: Option<String>,
126    },
127    /// Explicitly disconnected (e.g. plugin detected a container
128    /// stop that the authority doesn't know about yet).
129    Disconnected {
130        #[serde(default)]
131        label: Option<String>,
132    },
133}
134
135impl RemoteIndicatorOverride {
136    /// Project into the Copy enum consumed by `element_style`.
137    pub fn state(&self) -> RemoteIndicatorState {
138        match self {
139            Self::Local => RemoteIndicatorState::Local,
140            Self::Connecting { .. } => RemoteIndicatorState::Connecting,
141            Self::Connected { .. } => RemoteIndicatorState::Connected,
142            Self::FailedAttach { .. } => RemoteIndicatorState::FailedAttach,
143            Self::Disconnected { .. } => RemoteIndicatorState::Disconnected,
144        }
145    }
146
147    /// Short label rendered inside the indicator element. Defaults
148    /// are chosen so an override with no `label`/`error` field still
149    /// displays something sensible.
150    pub fn label(&self) -> String {
151        match self {
152            Self::Local => "Local".to_string(),
153            Self::Connecting { label } => match label {
154                Some(s) if !s.is_empty() => format!("⠿ {}", s),
155                _ => "⠿ Connecting".to_string(),
156            },
157            Self::Connected { label } => label
158                .as_deref()
159                .filter(|s| !s.is_empty())
160                .unwrap_or("Connected")
161                .to_string(),
162            Self::FailedAttach { error } => match error {
163                Some(s) if !s.is_empty() => format!("Attach failed: {}", s),
164                _ => "Attach failed".to_string(),
165            },
166            Self::Disconnected { label } => match label {
167                Some(s) if !s.is_empty() => format!("{} (Disconnected)", s),
168                _ => "Disconnected".to_string(),
169            },
170        }
171    }
172}
173
174/// A single rendered status bar element with its text and styling info.
175struct RenderedElement {
176    text: String,
177    kind: ElementKind,
178    /// For `ElementKind::Custom` elements, the plugin-registered token
179    /// key (`"<plugin>:<token>"`) — preserved here so the layout pass
180    /// can record this element's screen area under the same key for
181    /// click dispatch. `None` for every built-in element kind.
182    token_key: Option<String>,
183}
184
185/// Three-state LSP status used by the status bar `Lsp` element.
186///
187/// Collapses the previous "running / auto_start-dormant / opt-in-dormant /
188/// nothing" fan-out into the three user-meaningful buckets the indicator
189/// actually needs to communicate:
190///
191/// - `On`            — at least one server for this language is running
192/// - `Off`           — configured servers exist for this language, none are running
193/// - `OffDismissed`  — like `Off`, but the user clicked "Disable" from the
194///                     popup; rendered with a muted style so it stops
195///                     shouting for attention while remaining clickable
196///                     (so the user can still open the popup to re-enable
197///                     or see install help).
198/// - `Error`         — at least one server for this language is in the Error state
199/// - `None`          — no LSP configured or running for this language
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
201pub enum LspIndicatorState {
202    #[default]
203    None,
204    On,
205    Off,
206    OffDismissed,
207    Error,
208}
209
210/// Editor state, theming, and runtime inputs needed to render a status bar frame.
211pub struct StatusBarContext<'a> {
212    pub state: &'a mut EditorState,
213    pub cursors: &'a crate::model::cursor::Cursors,
214    pub status_message: &'a Option<String>,
215    pub plugin_status_message: &'a Option<String>,
216    pub lsp_status: &'a str,
217    /// Three-state LSP indicator: On / Off / Error / None.  Drives the
218    /// indicator's background color independently of `warning_level` (the
219    /// latter still scopes whether a warning badge is shown on the right
220    /// side of the status bar).
221    pub lsp_indicator_state: LspIndicatorState,
222    pub theme: &'a crate::view::theme::Theme,
223    pub display_name: &'a str,
224    pub keybindings: &'a crate::input::keybindings::KeybindingResolver,
225    pub chord_state: &'a [(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
226    pub update_available: Option<&'a str>,
227    pub warning_level: WarningLevel,
228    pub general_warning_count: usize,
229    pub hover: StatusBarHover,
230    pub remote_connection: Option<&'a str>,
231    pub session_name: Option<&'a str>,
232    pub read_only: bool,
233    /// Plugin-supplied override for the `{remote}` indicator. When
234    /// `Some`, its state+label are rendered instead of the one
235    /// derived from `remote_connection`. Set via the
236    /// `SetRemoteIndicatorState` plugin op; cleared by
237    /// `ClearRemoteIndicatorState` or by a `None` pass at the call
238    /// site.
239    pub remote_state_override: Option<&'a RemoteIndicatorOverride>,
240    /// True when the active buffer is the synthesized placeholder kept
241    /// alive by the close path with `auto_create_empty_buffer_on_last_buffer_close`
242    /// disabled. Buffer-specific elements (filename, cursor, line ending,
243    /// encoding, language, diagnostics) suppress themselves so the bar
244    /// reflects "no real buffer is open" rather than `[No Name] | Ln 1, Col 1 …`.
245    pub is_synthetic_placeholder: bool,
246    /// True when the user's status-bar layout contains the
247    /// `RemoteIndicator` element. Set by the renderer after
248    /// inspecting `StatusBarConfig.left` / `.right`. Read by the
249    /// `Filename` element's branch to decide whether to emit the
250    /// legacy `[Container:<id>] ` / SSH prefix on the filename
251    /// — when the dedicated indicator is on the bar that prefix
252    /// is redundant; when it's not, the filename keeps the prefix
253    /// so users still see the connection at a glance.
254    pub remote_indicator_on_bar: bool,
255    /// Values of custom status bar elements registered by plugins.
256    /// Key: "plugin_name:token_name", Value: current value to render.
257    /// Populated by `render.rs` before rendering.
258    pub dynamic_status_bar_elements: HashMap<String, String>,
259    /// Active session's workspace-trust level. Drives the always-present
260    /// `{trust}` indicator (read from the active authority each frame, so it
261    /// never goes stale or vanishes — unlike a per-buffer plugin token).
262    pub workspace_trust_level: crate::services::workspace_trust::TrustLevel,
263}
264
265/// Layout information returned from status bar rendering for mouse click detection
266#[derive(Debug, Clone, Default)]
267pub struct StatusBarLayout {
268    /// LSP indicator area (row, start_col, end_col) - None if no LSP indicator shown
269    pub lsp_indicator: Option<(u16, u16, u16)>,
270    /// Warning badge area (row, start_col, end_col) - None if no warnings
271    pub warning_badge: Option<(u16, u16, u16)>,
272    /// Line ending indicator area (row, start_col, end_col)
273    pub line_ending_indicator: Option<(u16, u16, u16)>,
274    /// Encoding indicator area (row, start_col, end_col)
275    pub encoding_indicator: Option<(u16, u16, u16)>,
276    /// Language indicator area (row, start_col, end_col)
277    pub language_indicator: Option<(u16, u16, u16)>,
278    /// Status message area (row, start_col, end_col) - clickable to show full history
279    pub message_area: Option<(u16, u16, u16)>,
280    /// Remote authority indicator area (row, start_col, end_col) - clickable
281    /// to open the remote-authority context menu.
282    pub remote_indicator: Option<(u16, u16, u16)>,
283    /// Workspace-trust indicator area (row, start_col, end_col) - clickable
284    /// to open the workspace-trust prompt.
285    pub trust_indicator: Option<(u16, u16, u16)>,
286    /// Plugin-registered status-bar token areas, keyed by the
287    /// `"<plugin_name>:<token_name>"` registry key (same key the
288    /// editor uses in `status_bar_token_registry`). Populated by the
289    /// renderer when it draws each plugin token. Mouse click dispatch
290    /// (`handle_click_status_bar`) walks this map after the built-in
291    /// indicators; on a hit, it fires the `status_bar_token_clicked`
292    /// hook so the plugin can react. This is what makes the env
293    /// pill, trust chip, and any future plugin chip first-class
294    /// affordances back to their decisions — see
295    /// `docs/internal/trust-env-devcontainer-ux-plan.md`
296    /// §"Path from here to the North Star".
297    pub plugin_token_areas: std::collections::HashMap<String, (u16, u16, u16)>,
298    /// Every rendered element, in screen order, with its semantic name, text and
299    /// cell position. This is the status bar's semantic model: a frontend renders
300    /// it directly (web) instead of scraping the drawn cells, and the TUI cell
301    /// rendering is just one consumer of the same data.
302    pub segments: Vec<StatusSegmentInfo>,
303}
304
305/// One rendered status-bar element, captured semantically (text + position)
306/// alongside the cell drawing so the web can render it natively.
307#[derive(Debug, Clone)]
308pub struct StatusSegmentInfo {
309    /// Semantic kind: "lsp" | "warning" | "language" | "encoding" |
310    /// "lineEnding" | "remote" | "trust" | "message" | "plugin" | "text".
311    pub name: &'static str,
312    /// Plugin token key for `name == "plugin"`.
313    pub key: Option<String>,
314    pub text: String,
315    pub x: u16,
316    pub w: u16,
317    /// Which side of the bar the renderer tiled this segment on: "left" or
318    /// "right". Carried from the actual left/right render passes so the web
319    /// orders/justifies segments exactly as the TUI does (rather than
320    /// re-deriving from a midpoint of `x`).
321    pub side: &'static str,
322}
323
324/// Map an [`ElementKind`] to the stable semantic name `status_view` uses.
325fn element_kind_name(kind: ElementKind) -> &'static str {
326    match kind {
327        ElementKind::Lsp => "lsp",
328        ElementKind::WarningBadge => "warning",
329        ElementKind::Language => "language",
330        ElementKind::Encoding => "encoding",
331        ElementKind::LineEnding => "lineEnding",
332        ElementKind::RemoteIndicator(_) => "remote",
333        ElementKind::WorkspaceTrust(_) => "trust",
334        ElementKind::Messages => "message",
335        ElementKind::Custom => "plugin",
336        _ => "text",
337    }
338}
339
340/// Status bar hover state for styling clickable indicators
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
342pub enum StatusBarHover {
343    #[default]
344    None,
345    /// Mouse is over the LSP indicator
346    LspIndicator,
347    /// Mouse is over the warning badge
348    WarningBadge,
349    /// Mouse is over the line ending indicator
350    LineEndingIndicator,
351    /// Mouse is over the encoding indicator
352    EncodingIndicator,
353    /// Mouse is over the language indicator
354    LanguageIndicator,
355    /// Mouse is over the status message area
356    MessageArea,
357    /// Mouse is over the remote authority indicator
358    RemoteIndicator,
359    /// Mouse is over the workspace-trust indicator
360    WorkspaceTrust,
361}
362
363/// Which search option checkbox is being hovered
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
365pub enum SearchOptionsHover {
366    #[default]
367    None,
368    CaseSensitive,
369    WholeWord,
370    Regex,
371    ConfirmEach,
372}
373
374/// Layout information for search options bar hit testing
375#[derive(Debug, Clone, Default)]
376pub struct SearchOptionsLayout {
377    /// Row where the search options are rendered
378    pub row: u16,
379    /// Case Sensitive checkbox area (start_col, end_col)
380    pub case_sensitive: Option<(u16, u16)>,
381    /// Whole Word checkbox area (start_col, end_col)
382    pub whole_word: Option<(u16, u16)>,
383    /// Regex checkbox area (start_col, end_col)
384    pub regex: Option<(u16, u16)>,
385    /// Confirm Each checkbox area (start_col, end_col) - only present in replace mode
386    pub confirm_each: Option<(u16, u16)>,
387}
388
389impl SearchOptionsLayout {
390    /// Check which search option checkbox (if any) is at the given position
391    pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
392        if y != self.row {
393            return None;
394        }
395
396        if let Some((start, end)) = self.case_sensitive {
397            if x >= start && x < end {
398                return Some(SearchOptionsHover::CaseSensitive);
399            }
400        }
401        if let Some((start, end)) = self.whole_word {
402            if x >= start && x < end {
403                return Some(SearchOptionsHover::WholeWord);
404            }
405        }
406        if let Some((start, end)) = self.regex {
407            if x >= start && x < end {
408                return Some(SearchOptionsHover::Regex);
409            }
410        }
411        if let Some((start, end)) = self.confirm_each {
412            if x >= start && x < end {
413                return Some(SearchOptionsHover::ConfirmEach);
414            }
415        }
416        None
417    }
418}
419
420/// Result of truncating a path for display
421#[derive(Debug, Clone)]
422pub struct TruncatedPath {
423    /// The first component(s) of the path (e.g. "/home" or "C:\Users")
424    pub prefix: String,
425    /// Whether truncation occurred (if true, display "[...]" between prefix and suffix)
426    pub truncated: bool,
427    /// The last components of the path (e.g. "project/src")
428    pub suffix: String,
429    /// The path's own separator, reused when re-joining the prefix /
430    /// ellipsis / suffix so a Windows `\`-path doesn't display with `/`.
431    pub sep: char,
432}
433
434impl TruncatedPath {
435    /// Get the full display string (without styling)
436    pub fn to_string_plain(&self) -> String {
437        if self.truncated {
438            format!("{}{}[...]{}", self.prefix, self.sep, self.suffix)
439        } else {
440            format!("{}{}", self.prefix, self.suffix)
441        }
442    }
443
444    /// Get the display length. The ellipsis marker is one separator column
445    /// plus "[...]" regardless of which separator is in use.
446    pub fn display_len(&self) -> usize {
447        if self.truncated {
448            self.prefix.len() + self.sep.len_utf8() + "[...]".len() + self.suffix.len()
449        } else {
450            self.prefix.len() + self.suffix.len()
451        }
452    }
453}
454
455/// The separator a path string is written with: `\` for a Windows-style
456/// path (so it round-trips natively), `/` otherwise. Splitting always
457/// accepts both — this only decides how pieces are re-joined for display.
458fn path_display_sep(path_str: &str) -> char {
459    if path_str.contains('\\') {
460        '\\'
461    } else {
462        '/'
463    }
464}
465
466/// Truncate a path for display, showing the first component, [...], and last components
467///
468/// For example, `/private/var/folders/p6/nlmq.../T/.tmpNYt4Fc/project/file.txt`
469/// becomes `/private/[...]/project/file.txt`
470///
471/// # Arguments
472/// * `path` - The path to truncate
473/// * `max_len` - Maximum length for the display string
474///
475/// # Returns
476/// A TruncatedPath struct with prefix, truncation indicator, and suffix
477pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
478    let path_str = path.to_string_lossy();
479    // Re-join pieces with the path's own separator so a Windows `\`-path
480    // doesn't render as `C:/[...]/x`. Splitting accepts both separators —
481    // crucially so a `\`-path isn't treated as one giant component (which
482    // previously forced the crude end-truncation branch on Windows).
483    let sep = path_display_sep(&path_str);
484
485    // If path fits, return as-is
486    if path_str.len() <= max_len {
487        return TruncatedPath {
488            prefix: String::new(),
489            truncated: false,
490            suffix: path_str.to_string(),
491            sep,
492        };
493    }
494
495    let components: Vec<&str> = path_str
496        .split(['/', '\\'])
497        .filter(|s| !s.is_empty())
498        .collect();
499
500    if components.is_empty() {
501        return TruncatedPath {
502            prefix: sep.to_string(),
503            truncated: false,
504            suffix: String::new(),
505            sep,
506        };
507    }
508
509    // Keep "root + first directory" as the prefix, like the Unix display
510    // (`/private/[...]`). A Windows drive letter ("C:") plays the part of
511    // the root, so keep `C:\<firstdir>` to stay symmetric instead of just
512    // the bare drive.
513    let leading_sep = path_str.starts_with('/') || path_str.starts_with('\\');
514    let is_drive = |c: &str| {
515        let b = c.as_bytes();
516        b.len() == 2 && b[1] == b':' && b[0].is_ascii_alphabetic()
517    };
518    let prefix_count = if !leading_sep && is_drive(components[0]) {
519        2
520    } else {
521        1
522    }
523    .min(components.len());
524    let sep_str = sep.to_string();
525    let prefix = {
526        let joined = components[..prefix_count].join(&sep_str);
527        if leading_sep {
528            format!("{}{}", sep, joined)
529        } else {
530            joined
531        }
532    };
533
534    // The "<sep>[...]" marker takes 6 bytes (separator + "[...]").
535    let ellipsis_len = sep.len_utf8() + "[...]".len();
536
537    // Calculate how much space we have for the suffix
538    let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
539
540    if available_for_suffix < 5 || components.len() <= prefix_count {
541        // Not enough space or nothing past the prefix, just truncate the
542        // end. Walk back to a char boundary so paths with non-ASCII
543        // components (e.g. `/home/ユーザー/project`) don't byte-slice
544        // through a multi-byte UTF-8 sequence and panic (same class as
545        // #1718).
546        let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
547            let cut = path_str.floor_char_boundary(max_len.saturating_sub(3));
548            format!("{}...", &path_str[..cut])
549        } else {
550            path_str.to_string()
551        };
552        return TruncatedPath {
553            prefix: String::new(),
554            truncated: false,
555            suffix: truncated_path,
556            sep,
557        };
558    }
559
560    // Build suffix from the last components that fit
561    let mut suffix_parts: Vec<&str> = Vec::new();
562    let mut suffix_len = 0;
563
564    for component in components.iter().skip(prefix_count).rev() {
565        let component_len = component.len() + 1; // +1 for the separator
566        if suffix_len + component_len <= available_for_suffix {
567            suffix_parts.push(component);
568            suffix_len += component_len;
569        } else {
570            break;
571        }
572    }
573
574    suffix_parts.reverse();
575
576    // If we included all remaining components, no truncation needed
577    if suffix_parts.len() == components.len() - prefix_count {
578        return TruncatedPath {
579            prefix: String::new(),
580            truncated: false,
581            suffix: path_str.to_string(),
582            sep,
583        };
584    }
585
586    let suffix = if suffix_parts.is_empty() {
587        // Can't fit any suffix components, truncate the last component.
588        // floor_char_boundary keeps the slice on a valid UTF-8 boundary
589        // when `last` contains non-ASCII characters.
590        let last = components.last().unwrap_or(&"");
591        let truncate_to = available_for_suffix.saturating_sub(4); // "/.." and some chars
592        if truncate_to > 0 && last.len() > truncate_to {
593            let cut = last.floor_char_boundary(truncate_to);
594            format!("{}{}...", sep, &last[..cut])
595        } else {
596            format!("{}{}", sep, last)
597        }
598    } else {
599        format!("{}{}", sep, suffix_parts.join(&sep_str))
600    };
601
602    TruncatedPath {
603        prefix,
604        truncated: true,
605        suffix,
606        sep,
607    }
608}
609
610/// Truncate a string to fit within `max_width` display columns, appending "..." if truncated.
611fn truncate_to_width(s: &str, max_width: usize) -> String {
612    let width = str_width(s);
613    if width <= max_width {
614        return s.to_string();
615    }
616    let truncate_at = max_width.saturating_sub(3);
617    if truncate_at == 0 {
618        return if max_width >= 3 {
619            "...".to_string()
620        } else {
621            s.chars().take(max_width).collect()
622        };
623    }
624    let mut w = 0;
625    let truncated: String = s
626        .chars()
627        .take_while(|ch| {
628            let cw = char_width(*ch);
629            if w + cw <= truncate_at {
630                w += cw;
631                true
632            } else {
633                false
634            }
635        })
636        .collect();
637    format!("{}...", truncated)
638}
639
640/// Minimum column-width to reserve for the column number portion of the
641/// cursor indicator. Chosen so the bar stays stable across lines with up
642/// to 3-digit column numbers without showing leading padding for the
643/// common single-digit case (the text is suffix-padded, not number-padded).
644const CURSOR_COL_RESERVE: usize = 3;
645
646/// Compute the cursor column as the number of grapheme clusters between the
647/// start of the cursor's line and the cursor. The line start is derived from
648/// the live cursor byte position (not a cached line number), so it stays
649/// correct in diff/split views where the two can disagree. Counting graphemes
650/// — rather than bytes or code points — keeps the reported column consistent
651/// with the editor's grapheme-based cursor movement.
652fn cursor_column(buffer: &mut crate::model::buffer::TextBuffer, cursor_position: usize) -> usize {
653    let mut iter = buffer.line_iterator(cursor_position, 80);
654    let line_start = iter.current_position();
655    let byte_col = cursor_position.saturating_sub(line_start);
656    if byte_col == 0 {
657        return 0;
658    }
659    // Prefer counting grapheme clusters over the line's text so multi-byte
660    // characters advance the column by one (issue #2090). Composite/diff
661    // buffers don't expose readable line content here; in that case fall back
662    // to the byte distance, which equals the grapheme count for the ASCII
663    // content those views render and matches the prior behavior.
664    match iter.next_line() {
665        Some((_, text)) if text.len() >= byte_col => {
666            let mut end = byte_col;
667            while end > 0 && !text.is_char_boundary(end) {
668                end -= 1;
669            }
670            crate::primitives::grapheme::grapheme_count(&text[..end])
671        }
672        _ => byte_col,
673    }
674}
675
676/// Format the cursor's `Ln X, Col Y` indicator so its rendered width is
677/// stable as the cursor moves. The numbers themselves are emitted with
678/// their natural width — preserving the format existing tests and screen-
679/// readers rely on — and trailing spaces are appended to reach a minimum
680/// width derived from the buffer's total line count and a fixed reserve
681/// for the column number. Fixes the status bar shifting reported in
682/// issue #1967.
683fn format_cursor_position(line: usize, col: usize, line_count: usize) -> String {
684    let text = format!("Ln {line}, Col {col}");
685    let line_digits = line_count.max(1).to_string().len();
686    // "Ln , Col " literals are 9 ASCII chars.
687    let min_width = 9 + line_digits + CURSOR_COL_RESERVE;
688    if text.len() < min_width {
689        format!("{text:<min_width$}")
690    } else {
691        text
692    }
693}
694
695/// Compact variant of `format_cursor_position`, used by
696/// `StatusBarElement::CursorCompact`. Renders as `line:col` with the same
697/// stable-width trailing-space strategy.
698fn format_cursor_position_compact(line: usize, col: usize, line_count: usize) -> String {
699    let text = format!("{line}:{col}");
700    let line_digits = line_count.max(1).to_string().len();
701    // ":" literal is 1 ASCII char.
702    let min_width = 1 + line_digits + CURSOR_COL_RESERVE;
703    if text.len() < min_width {
704        format!("{text:<min_width$}")
705    } else {
706        text
707    }
708}
709
710/// Renders the status bar and prompt/minibuffer
711pub struct StatusBarRenderer;
712
713impl StatusBarRenderer {
714    /// Render only the status bar (without prompt).
715    ///
716    /// Returns layout information with positions of clickable indicators.
717    pub fn render_status_bar(
718        frame: &mut Frame,
719        area: Rect,
720        ctx: &mut StatusBarContext<'_>,
721        config: &StatusBarConfig,
722        rec: Option<&mut CellThemeRecorder>,
723        // When false, build the full semantic model (`StatusBarLayout.segments` +
724        // indicator rects) but paint no cells — the web renders the status bar
725        // natively from `status_view`. The TUI always passes `true`.
726        draw: bool,
727    ) -> StatusBarLayout {
728        Self::render_status(frame, area, ctx, config, rec, draw)
729    }
730
731    /// Render the prompt/minibuffer
732    pub fn render_prompt(
733        frame: &mut Frame,
734        area: Rect,
735        prompt: &Prompt,
736        theme: &crate::view::theme::Theme,
737    ) {
738        let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
739
740        // Create spans for the prompt
741        let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
742
743        // If there's a selection, split the input into parts
744        if let Some((sel_start, sel_end)) = prompt.selection_range() {
745            let input = &prompt.input;
746
747            // Text before selection
748            if sel_start > 0 {
749                spans.push(Span::styled(input[..sel_start].to_string(), base_style));
750            }
751
752            // Selected text (blue background for visibility, cursor remains visible)
753            if sel_start < sel_end {
754                // Use theme colors for selection to ensure consistency across themes
755                let selection_style = Style::default()
756                    .fg(theme.prompt_selection_fg)
757                    .bg(theme.prompt_selection_bg);
758                spans.push(Span::styled(
759                    input[sel_start..sel_end].to_string(),
760                    selection_style,
761                ));
762            }
763
764            // Text after selection
765            if sel_end < input.len() {
766                spans.push(Span::styled(input[sel_end..].to_string(), base_style));
767            }
768        } else {
769            // No selection, render entire input normally
770            spans.push(Span::styled(prompt.input.clone(), base_style));
771        }
772
773        let line = Line::from(spans);
774        let prompt_line = Paragraph::new(line).style(base_style);
775
776        frame.render_widget(prompt_line, area);
777
778        // Set cursor position in the prompt
779        // Use display width (not byte length) for proper handling of:
780        // - Double-width CJK characters
781        // - Zero-width combining characters (Thai diacritics, etc.)
782        let message_width = str_width(&prompt.message);
783        let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
784        let cursor_x = (message_width + input_width_before_cursor) as u16;
785        if cursor_x < area.width {
786            frame.set_cursor_position((area.x + cursor_x, area.y));
787        }
788    }
789
790    /// Render the file open prompt with colorized path
791    /// Shows: "Open: /path/to/current/dir/filename" where the directory part is dimmed
792    /// Long paths are truncated: "/private/[...]/project/" with [...] styled differently
793    pub fn render_file_open_prompt(
794        frame: &mut Frame,
795        area: Rect,
796        prompt: &Prompt,
797        file_open_state: &crate::app::file_open::FileOpenState,
798        theme: &crate::view::theme::Theme,
799    ) {
800        let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
801        let dir_style = Style::default()
802            .fg(theme.help_separator_fg)
803            .bg(theme.prompt_bg);
804        // Style for the [...] ellipsis - use a more visible color
805        let ellipsis_style = Style::default()
806            .fg(theme.menu_highlight_fg)
807            .bg(theme.prompt_bg);
808
809        let mut spans = Vec::new();
810
811        // "Open: " prefix
812        let open_prompt = t!("file.open_prompt").to_string();
813        spans.push(Span::styled(open_prompt.clone(), base_style));
814
815        // Calculate if we need to truncate
816        // Only truncate if full path + input exceeds 90% of available width
817        let prefix_len = str_width(&open_prompt);
818        let dir_path = file_open_state.current_dir.to_string_lossy();
819        let dir_path_len = dir_path.len() + 1; // +1 for trailing slash
820        let input_len = prompt.input.len();
821        let total_len = prefix_len + dir_path_len + input_len;
822        let threshold = (area.width as usize * 90) / 100;
823
824        // Truncate the path only if total length exceeds 90% of width
825        let truncated = if total_len > threshold {
826            // Calculate how much space we have for the path after truncation
827            let available_for_path = threshold
828                .saturating_sub(prefix_len)
829                .saturating_sub(input_len);
830            truncate_path(&file_open_state.current_dir, available_for_path)
831        } else {
832            // No truncation needed - return full path
833            TruncatedPath {
834                prefix: String::new(),
835                truncated: false,
836                suffix: dir_path.to_string(),
837                sep: path_display_sep(&dir_path),
838            }
839        };
840
841        // Build the directory display with separate spans for styling
842        if truncated.truncated {
843            // Prefix (dimmed)
844            spans.push(Span::styled(truncated.prefix.clone(), dir_style));
845            // Ellipsis "<sep>[...]" (highlighted)
846            spans.push(Span::styled(
847                format!("{}[...]", truncated.sep),
848                ellipsis_style,
849            ));
850            // Suffix with trailing slash (dimmed)
851            let suffix_with_slash = if truncated.suffix.ends_with('/') {
852                truncated.suffix.clone()
853            } else {
854                format!("{}/", truncated.suffix)
855            };
856            spans.push(Span::styled(suffix_with_slash, dir_style));
857        } else {
858            // No truncation - just show the path with trailing slash
859            let path_display = if truncated.suffix.ends_with('/') {
860                truncated.suffix.clone()
861            } else {
862                format!("{}/", truncated.suffix)
863            };
864            spans.push(Span::styled(path_display, dir_style));
865        }
866
867        // User input (the filename part) - normal color
868        spans.push(Span::styled(prompt.input.clone(), base_style));
869
870        let line = Line::from(spans);
871        let prompt_line = Paragraph::new(line).style(base_style);
872
873        frame.render_widget(prompt_line, area);
874
875        // Set cursor position in the prompt
876        // Use display width for proper handling of Unicode characters
877        // We need to calculate the visual width of: "Open: " + dir_display + input[..cursor_pos]
878        let prefix_width = str_width(&open_prompt);
879        let dir_display_width = if truncated.truncated {
880            let suffix_with_slash = if truncated.suffix.ends_with('/') {
881                &truncated.suffix
882            } else {
883                // We already added "/" in the suffix_with_slash above, so approximate
884                &truncated.suffix
885            };
886            str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
887        } else {
888            str_width(&truncated.suffix) + 1 // +1 for trailing slash
889        };
890        let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
891        let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
892        if cursor_x < area.width {
893            frame.set_cursor_position((area.x + cursor_x, area.y));
894        }
895    }
896
897    /// Render a single element to its text representation.
898    /// Returns None if the element has nothing to display.
899    fn render_element(
900        element: &StatusBarElement,
901        ctx: &mut StatusBarContext<'_>,
902    ) -> Option<RenderedElement> {
903        // Buffer-specific elements have nothing meaningful to show when
904        // the active buffer is just a synthesized placeholder kept alive
905        // for editor invariants. Suppress them so the status bar tells
906        // the truth: there's no real file open.
907        if ctx.is_synthetic_placeholder
908            && matches!(
909                element,
910                StatusBarElement::Filename
911                    | StatusBarElement::Cursor
912                    | StatusBarElement::CursorCompact
913                    | StatusBarElement::CursorCount
914                    | StatusBarElement::Diagnostics
915                    | StatusBarElement::LineEnding
916                    | StatusBarElement::Encoding
917                    | StatusBarElement::Language
918            )
919        {
920            return None;
921        }
922        match element {
923            StatusBarElement::Filename => {
924                let modified = if ctx.state.buffer.is_modified() {
925                    " [+]"
926                } else {
927                    ""
928                };
929                let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
930                let remote_disconnected = ctx
931                    .remote_connection
932                    .map(|conn| conn.contains("(Disconnected)"))
933                    .unwrap_or(false);
934                // The `[Container:<id>] ` / `<SSH_PREFIX>conn<...>`
935                // prefix is redundant when the dedicated `{remote}`
936                // indicator is on the bar — same identity, two
937                // places. Skip it then. When `{remote}` is NOT on
938                // the bar, keep the prefix so users still see the
939                // connection at a glance from the filename.
940                let remote_prefix = if ctx.remote_indicator_on_bar {
941                    String::new()
942                } else {
943                    ctx.remote_connection
944                        .map(|conn| {
945                            if conn.starts_with("Container:") {
946                                format!("[{}] ", conn)
947                            } else {
948                                format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}")
949                            }
950                        })
951                        .unwrap_or_default()
952                };
953                let session_prefix = ctx
954                    .session_name
955                    .map(|name| format!("[{}] ", name))
956                    .unwrap_or_default();
957                let display_name = ctx.display_name;
958                let text = format!(
959                    "{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
960                );
961                let kind = if remote_disconnected {
962                    ElementKind::RemoteDisconnected
963                } else {
964                    ElementKind::Normal
965                };
966                Some(RenderedElement {
967                    text,
968                    kind,
969                    token_key: None,
970                })
971            }
972            StatusBarElement::Cursor => {
973                if !ctx.state.show_cursors {
974                    return None;
975                }
976                let cursor = *ctx.cursors.primary();
977                let line_count = ctx.state.buffer.line_count();
978                let text = if let Some(lc) = line_count {
979                    let line = ctx.state.primary_cursor_line_number.value();
980                    let col = cursor_column(&mut ctx.state.buffer, cursor.position);
981                    format_cursor_position(line + 1, col + 1, lc)
982                } else {
983                    format!("Byte {}", cursor.position)
984                };
985                Some(RenderedElement {
986                    text,
987                    kind: ElementKind::Normal,
988                    token_key: None,
989                })
990            }
991            StatusBarElement::CursorCompact => {
992                if !ctx.state.show_cursors {
993                    return None;
994                }
995                let cursor = *ctx.cursors.primary();
996                let line_count = ctx.state.buffer.line_count();
997                let text = if let Some(lc) = line_count {
998                    let line = ctx.state.primary_cursor_line_number.value();
999                    let col = cursor_column(&mut ctx.state.buffer, cursor.position);
1000                    format_cursor_position_compact(line + 1, col + 1, lc)
1001                } else {
1002                    format!("{}", cursor.position)
1003                };
1004                Some(RenderedElement {
1005                    text,
1006                    kind: ElementKind::Normal,
1007                    token_key: None,
1008                })
1009            }
1010            StatusBarElement::Diagnostics => {
1011                let diagnostics = ctx.state.overlays.all();
1012                let mut error_count = 0usize;
1013                let mut warning_count = 0usize;
1014                let mut info_count = 0usize;
1015                let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
1016                for overlay in diagnostics {
1017                    if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
1018                        match overlay.priority {
1019                            100 => error_count += 1,
1020                            50 => warning_count += 1,
1021                            _ => info_count += 1,
1022                        }
1023                    }
1024                }
1025                if error_count + warning_count + info_count == 0 {
1026                    return None;
1027                }
1028                let mut parts = Vec::new();
1029                if error_count > 0 {
1030                    parts.push(format!("E:{}", error_count));
1031                }
1032                if warning_count > 0 {
1033                    parts.push(format!("W:{}", warning_count));
1034                }
1035                if info_count > 0 {
1036                    parts.push(format!("I:{}", info_count));
1037                }
1038                Some(RenderedElement {
1039                    text: parts.join(" "),
1040                    kind: ElementKind::Normal,
1041                    token_key: None,
1042                })
1043            }
1044            StatusBarElement::CursorCount => {
1045                if ctx.cursors.count() <= 1 {
1046                    return None;
1047                }
1048                Some(RenderedElement {
1049                    text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
1050                    kind: ElementKind::Normal,
1051                    token_key: None,
1052                })
1053            }
1054            StatusBarElement::Messages => {
1055                let mut parts: Vec<&str> = Vec::new();
1056                if let Some(msg) = ctx.status_message {
1057                    if !msg.is_empty() {
1058                        parts.push(msg);
1059                    }
1060                }
1061                if let Some(msg) = ctx.plugin_status_message {
1062                    if !msg.is_empty() {
1063                        parts.push(msg);
1064                    }
1065                }
1066                if parts.is_empty() {
1067                    return None;
1068                }
1069                Some(RenderedElement {
1070                    text: parts.join(" | "),
1071                    kind: ElementKind::Messages,
1072                    token_key: None,
1073                })
1074            }
1075            StatusBarElement::Chord => {
1076                if ctx.chord_state.is_empty() {
1077                    return None;
1078                }
1079                let chord_str = ctx
1080                    .chord_state
1081                    .iter()
1082                    .map(|(code, modifiers)| {
1083                        crate::input::keybindings::format_keybinding(code, modifiers)
1084                    })
1085                    .collect::<Vec<_>>()
1086                    .join(" ");
1087                Some(RenderedElement {
1088                    text: format!("[{}]", chord_str),
1089                    kind: ElementKind::Normal,
1090                    token_key: None,
1091                })
1092            }
1093            StatusBarElement::LineEnding => Some(RenderedElement {
1094                text: ctx.state.buffer.line_ending().display_name().to_string(),
1095                kind: ElementKind::LineEnding,
1096                token_key: None,
1097            }),
1098            StatusBarElement::Encoding => Some(RenderedElement {
1099                text: ctx.state.buffer.encoding().display_name().to_string(),
1100                kind: ElementKind::Encoding,
1101                token_key: None,
1102            }),
1103            StatusBarElement::Language => {
1104                let text = if ctx.state.language == "text"
1105                    && ctx.state.display_name != "Text"
1106                    && ctx.state.display_name != "Plain Text"
1107                    && ctx.state.display_name != "text"
1108                {
1109                    format!("{} [syntax only]", &ctx.state.display_name)
1110                } else {
1111                    ctx.state.display_name.to_string()
1112                };
1113                Some(RenderedElement {
1114                    text,
1115                    kind: ElementKind::Language,
1116                    token_key: None,
1117                })
1118            }
1119            StatusBarElement::Lsp => {
1120                if ctx.lsp_status.is_empty() {
1121                    return None;
1122                }
1123                Some(RenderedElement {
1124                    text: ctx.lsp_status.to_string(),
1125                    kind: ElementKind::Lsp,
1126                    token_key: None,
1127                })
1128            }
1129            StatusBarElement::Warnings => {
1130                if ctx.general_warning_count == 0 {
1131                    return None;
1132                }
1133                Some(RenderedElement {
1134                    text: format!("[\u{26a0} {}]", ctx.general_warning_count),
1135                    kind: ElementKind::WarningBadge,
1136                    token_key: None,
1137                })
1138            }
1139            StatusBarElement::Update => {
1140                let version = ctx.update_available?;
1141                Some(RenderedElement {
1142                    text: t!("status.update_available", version = version).to_string(),
1143                    kind: ElementKind::Update,
1144                    token_key: None,
1145                })
1146            }
1147            StatusBarElement::Palette => {
1148                let shortcut = ctx
1149                    .keybindings
1150                    .get_keybinding_for_action(
1151                        &crate::input::keybindings::Action::QuickOpen,
1152                        crate::input::keybindings::KeyContext::Global,
1153                    )
1154                    .unwrap_or_else(|| "?".to_string());
1155                Some(RenderedElement {
1156                    text: t!("status.palette", shortcut = shortcut).to_string(),
1157                    kind: ElementKind::Palette,
1158                    token_key: None,
1159                })
1160            }
1161            StatusBarElement::Clock => {
1162                let now = chrono::Local::now();
1163                let text = format!("{:02}:{:02}", now.hour(), now.minute());
1164                Some(RenderedElement {
1165                    text,
1166                    kind: ElementKind::Clock,
1167                    token_key: None,
1168                })
1169            }
1170            StatusBarElement::RemoteIndicator => {
1171                // Persistent remote-authority entry point. When local we
1172                // still emit a short label so the indicator is visible —
1173                // the spec calls for a persistent control, not one that
1174                // vanishes when there is nothing to report.
1175                //
1176                // Precedence: plugin-supplied override (via
1177                // `SetRemoteIndicatorState`) wins over the authority-
1178                // derived state. The override carries its own label;
1179                // derived states synthesize one from `remote_connection`.
1180                let (text, state) = if let Some(over) = ctx.remote_state_override {
1181                    (over.label(), over.state())
1182                } else {
1183                    match ctx.remote_connection {
1184                        None => ("Local".to_string(), RemoteIndicatorState::Local),
1185                        Some(conn) if conn.contains("(Disconnected)") => {
1186                            (conn.to_string(), RemoteIndicatorState::Disconnected)
1187                        }
1188                        Some(conn) => (conn.to_string(), RemoteIndicatorState::Connected),
1189                    }
1190                };
1191                Some(RenderedElement {
1192                    text,
1193                    kind: ElementKind::RemoteIndicator(state),
1194                    token_key: None,
1195                })
1196            }
1197            StatusBarElement::WorkspaceTrust => {
1198                // Always-present trust control, read from the active session's
1199                // trust level each frame. Persistent like `{remote}` — it never
1200                // vanishes, so the user always knows whether repo-controlled
1201                // execution is gated. Capitalized for a status-bar label.
1202                use crate::services::workspace_trust::TrustLevel;
1203                let level = ctx.workspace_trust_level;
1204                let text = match level {
1205                    TrustLevel::Trusted => t!("statusbar.trust.trusted"),
1206                    TrustLevel::Restricted => t!("statusbar.trust.restricted"),
1207                    TrustLevel::Blocked => t!("statusbar.trust.blocked"),
1208                }
1209                .to_string();
1210                Some(RenderedElement {
1211                    text,
1212                    kind: ElementKind::WorkspaceTrust(level),
1213                    token_key: None,
1214                })
1215            }
1216            StatusBarElement::CustomToken(key) => {
1217                if let Some(value) = ctx.dynamic_status_bar_elements.get(key) {
1218                    Some(RenderedElement {
1219                        text: value.clone(),
1220                        kind: ElementKind::Custom,
1221                        token_key: Some(key.clone()),
1222                    })
1223                } else {
1224                    None // Skip rendering if no value set
1225                }
1226            }
1227        }
1228    }
1229
1230    /// Get the style for a rendered element based on its kind, theme, and hover state.
1231    fn element_style(
1232        kind: ElementKind,
1233        theme: &crate::view::theme::Theme,
1234        hover: StatusBarHover,
1235        _warning_level: WarningLevel,
1236        lsp_state: LspIndicatorState,
1237    ) -> Style {
1238        match kind {
1239            ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
1240                .fg(theme.status_bar_fg)
1241                .bg(theme.status_bar_bg),
1242            ElementKind::RemoteDisconnected => Style::default()
1243                .fg(theme.status_error_indicator_fg)
1244                .bg(theme.status_error_indicator_bg),
1245            ElementKind::LineEnding => {
1246                let is_hovering = hover == StatusBarHover::LineEndingIndicator;
1247                let (fg, bg) = if is_hovering {
1248                    (theme.menu_hover_fg, theme.menu_hover_bg)
1249                } else {
1250                    (theme.status_bar_fg, theme.status_bar_bg)
1251                };
1252                let mut style = Style::default().fg(fg).bg(bg);
1253                if is_hovering {
1254                    style = style.add_modifier(Modifier::UNDERLINED);
1255                }
1256                style
1257            }
1258            ElementKind::Encoding => {
1259                let is_hovering = hover == StatusBarHover::EncodingIndicator;
1260                let (fg, bg) = if is_hovering {
1261                    (theme.menu_hover_fg, theme.menu_hover_bg)
1262                } else {
1263                    (theme.status_bar_fg, theme.status_bar_bg)
1264                };
1265                let mut style = Style::default().fg(fg).bg(bg);
1266                if is_hovering {
1267                    style = style.add_modifier(Modifier::UNDERLINED);
1268                }
1269                style
1270            }
1271            ElementKind::Language => {
1272                let is_hovering = hover == StatusBarHover::LanguageIndicator;
1273                let (fg, bg) = if is_hovering {
1274                    (theme.menu_hover_fg, theme.menu_hover_bg)
1275                } else {
1276                    (theme.status_bar_fg, theme.status_bar_bg)
1277                };
1278                let mut style = Style::default().fg(fg).bg(bg);
1279                if is_hovering {
1280                    style = style.add_modifier(Modifier::UNDERLINED);
1281                }
1282                style
1283            }
1284            ElementKind::Lsp => {
1285                let is_hovering = hover == StatusBarHover::LspIndicator;
1286                // Color by LSP state:
1287                //   Error  → diagnostic_error_*       (red-ish; problem)
1288                //   Off    → status_lsp_actionable_*  (prominent; click to act)
1289                //   On     → status_lsp_on_*          (neutral; healthy)
1290                //   Dismissed/None → status-bar palette (muted; nothing to do)
1291                //
1292                // Off is the indicator's main signal that the user has
1293                // useful options behind a click — drawn prominently so
1294                // it stands out in the status bar without auto-popping
1295                // a dialog.
1296                let (fg, bg) = match lsp_state {
1297                    LspIndicatorState::Error => {
1298                        (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
1299                    }
1300                    LspIndicatorState::Off => (
1301                        theme.status_lsp_actionable_fg,
1302                        theme.status_lsp_actionable_bg,
1303                    ),
1304                    LspIndicatorState::On => (theme.status_lsp_on_fg, theme.status_lsp_on_bg),
1305                    LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
1306                    LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
1307                };
1308                let mut style = Style::default().fg(fg).bg(bg);
1309                // Always underline on hover — the indicator is clickable
1310                // in all non-empty states.  Previously we only underlined
1311                // when warning_level != None, so "LSP (on)" gave no hover
1312                // cue that it was clickable.
1313                if is_hovering && lsp_state != LspIndicatorState::None {
1314                    style = style.add_modifier(Modifier::UNDERLINED);
1315                }
1316                style
1317            }
1318            ElementKind::WarningBadge => {
1319                let is_hovering = hover == StatusBarHover::WarningBadge;
1320                let (fg, bg) = if is_hovering {
1321                    (
1322                        theme.status_warning_indicator_hover_fg,
1323                        theme.status_warning_indicator_hover_bg,
1324                    )
1325                } else {
1326                    (
1327                        theme.status_warning_indicator_fg,
1328                        theme.status_warning_indicator_bg,
1329                    )
1330                };
1331                let mut style = Style::default().fg(fg).bg(bg);
1332                if is_hovering {
1333                    style = style.add_modifier(Modifier::UNDERLINED);
1334                }
1335                style
1336            }
1337            ElementKind::Update => Style::default()
1338                .fg(theme.menu_highlight_fg)
1339                .bg(theme.menu_dropdown_bg),
1340            // The palette shortcut hint is purely informational — driven
1341            // by the dedicated `status_palette_*` theme keys (default
1342            // to the neutral status-bar palette so it blends into the
1343            // bar instead of breaking the color band at the right edge).
1344            ElementKind::Palette => Style::default()
1345                .fg(theme.status_palette_fg)
1346                .bg(theme.status_palette_bg),
1347            ElementKind::Custom => Style::default()
1348                .fg(theme.status_bar_fg)
1349                .bg(theme.status_bar_bg),
1350            ElementKind::RemoteIndicator(state) => {
1351                let is_hovering = hover == StatusBarHover::RemoteIndicator;
1352                let (fg, bg) = match state {
1353                    // Connecting and Connected share the "help
1354                    // indicator" palette so the transition from one to
1355                    // the other is a glyph swap rather than a color
1356                    // flash — the user's eye tracks the indicator
1357                    // changing, not disappearing.
1358                    RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1359                        (theme.help_indicator_fg, theme.help_indicator_bg)
1360                    }
1361                    // FailedAttach + Disconnected share the error
1362                    // palette. Both are "the remote isn't reaching you
1363                    // right now" states, differing only in cause.
1364                    RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1365                        theme.status_error_indicator_fg,
1366                        theme.status_error_indicator_bg,
1367                    ),
1368                    // Local: neutral status-bar palette.
1369                    RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
1370                };
1371                let mut style = Style::default().fg(fg).bg(bg);
1372                if is_hovering {
1373                    style = style.add_modifier(Modifier::UNDERLINED);
1374                }
1375                style
1376            }
1377            ElementKind::WorkspaceTrust(level) => {
1378                use crate::services::workspace_trust::TrustLevel;
1379                let is_hovering = hover == StatusBarHover::WorkspaceTrust;
1380                let (fg, bg) = match level {
1381                    // Gated states reuse the warning indicator palette so
1382                    // "execution is restricted/blocked" reads at a glance.
1383                    TrustLevel::Restricted | TrustLevel::Blocked => (
1384                        theme.status_warning_indicator_fg,
1385                        theme.status_warning_indicator_bg,
1386                    ),
1387                    // Trusted: neutral status-bar palette (everything-works).
1388                    TrustLevel::Trusted => (theme.status_bar_fg, theme.status_bar_bg),
1389                };
1390                let mut style = Style::default().fg(fg).bg(bg);
1391                if is_hovering {
1392                    style = style.add_modifier(Modifier::UNDERLINED);
1393                }
1394                style
1395            }
1396        }
1397    }
1398
1399    /// The (fg, bg) theme-key strings an element paints with — its non-hover
1400    /// provenance for the theme inspector, mirroring `element_style`. Hover is
1401    /// transient so the recorded key is always the element's semantic key.
1402    fn element_keys(
1403        kind: ElementKind,
1404        lsp_state: LspIndicatorState,
1405    ) -> (&'static str, &'static str) {
1406        match kind {
1407            ElementKind::Normal
1408            | ElementKind::Messages
1409            | ElementKind::Clock
1410            | ElementKind::Custom
1411            | ElementKind::LineEnding
1412            | ElementKind::Encoding
1413            | ElementKind::Language => ("ui.status_bar_fg", "ui.status_bar_bg"),
1414            ElementKind::RemoteDisconnected => (
1415                "ui.status_error_indicator_fg",
1416                "ui.status_error_indicator_bg",
1417            ),
1418            ElementKind::Lsp => match lsp_state {
1419                LspIndicatorState::Error => ("diagnostic.error_fg", "diagnostic.error_bg"),
1420                LspIndicatorState::Off => {
1421                    ("ui.status_lsp_actionable_fg", "ui.status_lsp_actionable_bg")
1422                }
1423                LspIndicatorState::On => ("ui.status_lsp_on_fg", "ui.status_lsp_on_bg"),
1424                LspIndicatorState::OffDismissed | LspIndicatorState::None => {
1425                    ("ui.status_bar_fg", "ui.status_bar_bg")
1426                }
1427            },
1428            ElementKind::WarningBadge => (
1429                "ui.status_warning_indicator_fg",
1430                "ui.status_warning_indicator_bg",
1431            ),
1432            ElementKind::Update => ("ui.menu_highlight_fg", "ui.menu_dropdown_bg"),
1433            ElementKind::Palette => ("ui.status_palette_fg", "ui.status_palette_bg"),
1434            ElementKind::RemoteIndicator(state) => match state {
1435                RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1436                    ("ui.help_indicator_fg", "ui.help_indicator_bg")
1437                }
1438                RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1439                    "ui.status_error_indicator_fg",
1440                    "ui.status_error_indicator_bg",
1441                ),
1442                RemoteIndicatorState::Local => ("ui.status_bar_fg", "ui.status_bar_bg"),
1443            },
1444            ElementKind::WorkspaceTrust(level) => {
1445                use crate::services::workspace_trust::TrustLevel;
1446                match level {
1447                    TrustLevel::Restricted | TrustLevel::Blocked => (
1448                        "ui.status_warning_indicator_fg",
1449                        "ui.status_warning_indicator_bg",
1450                    ),
1451                    TrustLevel::Trusted => ("ui.status_bar_fg", "ui.status_bar_bg"),
1452                }
1453            }
1454        }
1455    }
1456
1457    /// Map a rendered element to the layout field(s) it should populate.
1458    /// Built-in indicators get their dedicated `Option<(row, start_col,
1459    /// end_col)>` slot. Plugin tokens (`ElementKind::Custom` carrying a
1460    /// `token_key`) get an entry in `plugin_token_areas`, keyed by the
1461    /// plugin's registry key — that's what `handle_click_status_bar`
1462    /// uses to dispatch clicks back to the right plugin.
1463    fn update_layout_for_element(
1464        layout: &mut StatusBarLayout,
1465        kind: ElementKind,
1466        token_key: Option<&str>,
1467        row: u16,
1468        start_col: u16,
1469        end_col: u16,
1470    ) {
1471        match kind {
1472            ElementKind::LineEnding => {
1473                layout.line_ending_indicator = Some((row, start_col, end_col))
1474            }
1475            ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
1476            ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
1477            ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
1478            ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
1479            ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
1480            ElementKind::RemoteIndicator(_) => {
1481                layout.remote_indicator = Some((row, start_col, end_col))
1482            }
1483            ElementKind::WorkspaceTrust(_) => {
1484                layout.trust_indicator = Some((row, start_col, end_col))
1485            }
1486            ElementKind::Custom => {
1487                if let Some(key) = token_key {
1488                    layout
1489                        .plugin_token_areas
1490                        .insert(key.to_string(), (row, start_col, end_col));
1491                }
1492            }
1493            _ => {}
1494        }
1495    }
1496
1497    /// Build the styled spans for a single rendered element, honoring the
1498    /// special-case two-color rendering for a disconnected remote filename.
1499    ///
1500    /// Returns the spans and the total display width of the emitted text.
1501    fn element_spans(
1502        rendered: &RenderedElement,
1503        theme: &crate::view::theme::Theme,
1504        hover: StatusBarHover,
1505        warning_level: WarningLevel,
1506        lsp_state: LspIndicatorState,
1507    ) -> (Vec<Span<'static>>, usize) {
1508        let base_style = Style::default()
1509            .fg(theme.status_bar_fg)
1510            .bg(theme.status_bar_bg);
1511        // Each entry carries a one-space margin on each side painted in its own
1512        // style, so entries with a distinct background (LSP / warnings / update
1513        // / palette / remote) render as a padded pill. The separator is then a
1514        // bare glyph drawn between these padded entries.
1515        let width = str_width(&rendered.text) + 2;
1516
1517        if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
1518        {
1519            let error_style = Style::default()
1520                .fg(theme.status_error_indicator_fg)
1521                .bg(theme.status_error_indicator_bg);
1522            if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
1523                let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
1524                let prefix = rendered.text[..split_at].to_string();
1525                let rest = rendered.text[split_at..].to_string();
1526                return (
1527                    vec![
1528                        Span::styled(" ", error_style),
1529                        Span::styled(prefix, error_style),
1530                        Span::styled(rest, base_style),
1531                        Span::styled(" ", base_style),
1532                    ],
1533                    width,
1534                );
1535            }
1536            return (
1537                vec![
1538                    Span::styled(" ", error_style),
1539                    Span::styled(rendered.text.clone(), error_style),
1540                    Span::styled(" ", error_style),
1541                ],
1542                width,
1543            );
1544        }
1545
1546        let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
1547        let mut spans = vec![Span::styled(" ", style)];
1548        if rendered.kind == ElementKind::Clock {
1549            // "HH:MM" — blink the colon via terminal hardware (SGR 5)
1550            spans.push(Span::styled(rendered.text[..2].to_string(), style));
1551            spans.push(Span::styled(
1552                ":".to_string(),
1553                style.add_modifier(Modifier::SLOW_BLINK),
1554            ));
1555            spans.push(Span::styled(rendered.text[3..].to_string(), style));
1556        } else {
1557            spans.push(Span::styled(rendered.text.clone(), style));
1558        }
1559        spans.push(Span::styled(" ", style));
1560        (spans, width)
1561    }
1562
1563    /// Render a configured side (left/right) into styled per-element groups.
1564    /// Each tuple carries the rendered spans, total width, the kind tag
1565    /// (for layout/click-area routing of built-ins), and the plugin
1566    /// token key (`Some` only for `ElementKind::Custom`) so the
1567    /// placement loops can record the screen area under the same key
1568    /// the plugin registered.
1569    fn render_side(
1570        config_side: &[StatusBarElement],
1571        ctx: &mut StatusBarContext<'_>,
1572    ) -> Vec<(Vec<Span<'static>>, usize, ElementKind, Option<String>)> {
1573        let rendered: Vec<RenderedElement> = config_side
1574            .iter()
1575            .filter_map(|elem| Self::render_element(elem, ctx))
1576            .filter(|e| !e.text.is_empty())
1577            .collect();
1578
1579        let theme = ctx.theme;
1580        let hover = ctx.hover;
1581        let warning_level = ctx.warning_level;
1582        let lsp_state = ctx.lsp_indicator_state;
1583        rendered
1584            .into_iter()
1585            .map(|r| {
1586                let kind = r.kind;
1587                let token_key = r.token_key.clone();
1588                let (spans, width) =
1589                    Self::element_spans(&r, theme, hover, warning_level, lsp_state);
1590                (spans, width, kind, token_key)
1591            })
1592            .collect()
1593    }
1594
1595    /// Render the normal status bar (config-driven).
1596    fn render_status(
1597        frame: &mut Frame,
1598        area: Rect,
1599        ctx: &mut StatusBarContext<'_>,
1600        config: &StatusBarConfig,
1601        mut rec: Option<&mut CellThemeRecorder>,
1602        draw: bool,
1603    ) -> StatusBarLayout {
1604        let mut layout = StatusBarLayout::default();
1605        let base_style = Style::default()
1606            .fg(ctx.theme.status_bar_fg)
1607            .bg(ctx.theme.status_bar_bg);
1608        let available_width = area.width as usize;
1609
1610        if available_width == 0 || area.height == 0 {
1611            return layout;
1612        }
1613
1614        // Lay down the bar's base keys across the whole row so padding / gaps
1615        // resolve to the status bar; each element below overwrites its own
1616        // cells with their specific keys as it's emitted.
1617        let lsp_state = ctx.lsp_indicator_state;
1618        if let Some(r) = rec.as_deref_mut() {
1619            r.run(
1620                area.x,
1621                area.y,
1622                area.width,
1623                Some("ui.status_bar_fg"),
1624                Some("ui.status_bar_bg"),
1625                "Status Bar",
1626            );
1627        }
1628
1629        // Tell the per-element renderer whether the dedicated
1630        // RemoteIndicator is on the bar so the Filename branch
1631        // can drop its now-redundant `[Container:<id>] ` /
1632        // SSH prefix.
1633        ctx.remote_indicator_on_bar = config
1634            .left
1635            .iter()
1636            .chain(config.right.iter())
1637            .any(|e| matches!(e, StatusBarElement::RemoteIndicator));
1638
1639        let left_items = Self::render_side(&config.left, ctx);
1640        let mut right_items = Self::render_side(&config.right, ctx);
1641
1642        // Separator drawn between elements, used verbatim from config.
1643        // An empty value disables separators and consumes no width.
1644        let separator: &str = &config.separator;
1645        let separator_width = str_width(separator);
1646        // The separator glyph is colored by the theme's dedicated separator
1647        // keys so it can be dimmed against the bar; both fall back to the bar.
1648        let separator_style = Style::default()
1649            .fg(ctx.theme.status_separator_fg)
1650            .bg(ctx.theme.status_separator_bg);
1651
1652        // Reserve a sane minimum for the left side so the buffer name and
1653        // cursor position aren't truncated to a single character on narrow
1654        // terminals (regression originally reported as
1655        // `t  LF  ASCII  Markdown ...`).  Drop low-priority right elements
1656        // (configured right-most first) until the remaining right side fits
1657        // alongside that minimum left budget.  We never drop the *first*
1658        // right element so the user keeps at least one piece of right-side
1659        // status if any was configured.
1660        let total_right_width: usize = right_items.iter().map(|(_, w, _, _)| *w).sum::<usize>()
1661            + separator_width * right_items.len().saturating_sub(1);
1662        let left_min_target = available_width
1663            .saturating_mul(2)
1664            .saturating_div(5) // ~40% of width reserved for left when feasible
1665            .min(40); // but never demand more than 40 cols even on wide terminals
1666        let right_budget = available_width.saturating_sub(left_min_target + 1);
1667        if total_right_width > right_budget && right_items.len() > 1 {
1668            let mut current = total_right_width;
1669            while current > right_budget && right_items.len() > 1 {
1670                if let Some(dropped) = right_items.pop() {
1671                    current = current.saturating_sub(dropped.1);
1672                    // Also remove the separator that preceded the dropped
1673                    // element (always present since we never drop the first)
1674                    current = current.saturating_sub(separator_width);
1675                } else {
1676                    break;
1677                }
1678            }
1679        }
1680
1681        let right_width: usize = right_items.iter().map(|(_, w, _, _)| *w).sum::<usize>()
1682            + separator_width * right_items.len().saturating_sub(1);
1683
1684        let narrow = available_width < 15;
1685        let left_max_width = if narrow {
1686            available_width
1687        } else if available_width > right_width + 1 {
1688            available_width - right_width - 1
1689        } else {
1690            1
1691        };
1692
1693        // Emit left side, consuming `left_items` so each element's spans move
1694        // directly into the output without a clone. Widths are cached so the
1695        // truncation check doesn't re-measure text.
1696        let mut spans: Vec<Span<'static>> = Vec::new();
1697        let mut used_left: usize = 0;
1698
1699        for (idx, (item_spans, width, kind, token_key)) in left_items.into_iter().enumerate() {
1700            let sep_width = if idx == 0 { 0 } else { separator_width };
1701            if used_left + sep_width >= left_max_width {
1702                break;
1703            }
1704            if sep_width > 0 {
1705                if let Some(r) = rec.as_deref_mut() {
1706                    r.run(
1707                        area.x + used_left as u16,
1708                        area.y,
1709                        sep_width as u16,
1710                        Some("ui.status_separator_fg"),
1711                        Some("ui.status_separator_bg"),
1712                        "Status Bar",
1713                    );
1714                }
1715                spans.push(Span::styled(separator.to_string(), separator_style));
1716                used_left += sep_width;
1717            }
1718
1719            let remaining = left_max_width - used_left;
1720            let start_col = used_left;
1721
1722            if width <= remaining {
1723                // `segments` is consumed only by the web (`status_view`); the
1724                // TUI (`draw`) never reads it, so don't allocate the per-segment
1725                // text/Vec on the terminal hot path.
1726                let seg_text = (!draw).then(|| {
1727                    item_spans
1728                        .iter()
1729                        .map(|s| s.content.as_ref())
1730                        .collect::<String>()
1731                });
1732                spans.extend(item_spans);
1733                used_left += width;
1734
1735                if let Some(r) = rec.as_deref_mut() {
1736                    let (fg, bg) = Self::element_keys(kind, lsp_state);
1737                    r.run(
1738                        area.x + start_col as u16,
1739                        area.y,
1740                        width as u16,
1741                        Some(fg),
1742                        Some(bg),
1743                        "Status Bar",
1744                    );
1745                }
1746
1747                Self::update_layout_for_element(
1748                    &mut layout,
1749                    kind,
1750                    token_key.as_deref(),
1751                    area.y,
1752                    area.x + start_col as u16,
1753                    area.x + (start_col + width) as u16,
1754                );
1755                if let Some(text) = seg_text {
1756                    layout.segments.push(StatusSegmentInfo {
1757                        name: element_kind_name(kind),
1758                        key: token_key.clone(),
1759                        text,
1760                        x: area.x + start_col as u16,
1761                        w: width as u16,
1762                        side: "left",
1763                    });
1764                }
1765            } else {
1766                // Overflow: truncate the concatenated text of this element.
1767                // Per-span styling is lost for the overflowed slice — we fall
1768                // back to whatever `element_style` would have returned.
1769                let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1770                let truncated = truncate_to_width(&group_text, remaining);
1771                let truncated_width = str_width(&truncated);
1772                let overflow_style = Self::element_style(
1773                    kind,
1774                    ctx.theme,
1775                    ctx.hover,
1776                    ctx.warning_level,
1777                    ctx.lsp_indicator_state,
1778                );
1779                let seg_text = (!draw).then(|| truncated.clone());
1780                spans.push(Span::styled(truncated, overflow_style));
1781
1782                if let Some(r) = rec.as_deref_mut() {
1783                    let (fg, bg) = Self::element_keys(kind, lsp_state);
1784                    r.run(
1785                        area.x + start_col as u16,
1786                        area.y,
1787                        truncated_width as u16,
1788                        Some(fg),
1789                        Some(bg),
1790                        "Status Bar",
1791                    );
1792                }
1793                used_left += truncated_width;
1794
1795                Self::update_layout_for_element(
1796                    &mut layout,
1797                    kind,
1798                    token_key.as_deref(),
1799                    area.y,
1800                    area.x + start_col as u16,
1801                    area.x + (start_col + truncated_width) as u16,
1802                );
1803                if let Some(text) = seg_text {
1804                    layout.segments.push(StatusSegmentInfo {
1805                        name: element_kind_name(kind),
1806                        key: token_key.clone(),
1807                        text,
1808                        x: area.x + start_col as u16,
1809                        w: truncated_width as u16,
1810                        side: "left",
1811                    });
1812                }
1813                break;
1814            }
1815        }
1816
1817        if narrow {
1818            if used_left < available_width {
1819                spans.push(Span::styled(
1820                    " ".repeat(available_width - used_left),
1821                    base_style,
1822                ));
1823            }
1824            if draw {
1825                frame.render_widget(Paragraph::new(Line::from(spans)), area);
1826            }
1827            return layout;
1828        }
1829
1830        let mut col_offset = used_left;
1831        if col_offset + right_width < available_width {
1832            let padding = available_width - col_offset - right_width;
1833            spans.push(Span::styled(" ".repeat(padding), base_style));
1834            col_offset = available_width - right_width;
1835        } else if col_offset < available_width {
1836            spans.push(Span::styled(" ", base_style));
1837            col_offset += 1;
1838        }
1839
1840        let mut current_col = area.x + col_offset as u16;
1841        for (idx, (item_spans, width, kind, token_key)) in right_items.into_iter().enumerate() {
1842            if idx > 0 && separator_width > 0 {
1843                if let Some(r) = rec.as_deref_mut() {
1844                    r.run(
1845                        current_col,
1846                        area.y,
1847                        separator_width as u16,
1848                        Some("ui.status_separator_fg"),
1849                        Some("ui.status_separator_bg"),
1850                        "Status Bar",
1851                    );
1852                }
1853                spans.push(Span::styled(separator.to_string(), separator_style));
1854                current_col += separator_width as u16;
1855            }
1856            if let Some(r) = rec.as_deref_mut() {
1857                let (fg, bg) = Self::element_keys(kind, lsp_state);
1858                r.run(
1859                    current_col,
1860                    area.y,
1861                    width as u16,
1862                    Some(fg),
1863                    Some(bg),
1864                    "Status Bar",
1865                );
1866            }
1867            Self::update_layout_for_element(
1868                &mut layout,
1869                kind,
1870                token_key.as_deref(),
1871                area.y,
1872                current_col,
1873                current_col + width as u16,
1874            );
1875            if !draw {
1876                // Web-only semantic model; skip the allocation on the TUI path.
1877                let seg_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1878                layout.segments.push(StatusSegmentInfo {
1879                    name: element_kind_name(kind),
1880                    key: token_key.clone(),
1881                    text: seg_text,
1882                    x: current_col,
1883                    w: width as u16,
1884                    side: "right",
1885                });
1886            }
1887            spans.extend(item_spans);
1888            current_col += width as u16;
1889        }
1890
1891        if draw {
1892            frame.render_widget(Paragraph::new(Line::from(spans)), area);
1893        }
1894        layout
1895    }
1896
1897    /// Render the search options bar (shown when search prompt is active)
1898    ///
1899    /// Displays checkboxes for search options with their keyboard shortcuts:
1900    /// - Case Sensitive (Alt+C)
1901    /// - Whole Word (Alt+W)
1902    /// - Regex (Alt+R)
1903    /// - Confirm Each (Alt+I) - only shown in replace mode
1904    ///
1905    /// # Returns
1906    /// Layout information for hit testing mouse clicks on checkboxes
1907    #[allow(clippy::too_many_arguments)]
1908    pub fn render_search_options(
1909        frame: &mut Frame,
1910        area: Rect,
1911        case_sensitive: bool,
1912        whole_word: bool,
1913        use_regex: bool,
1914        confirm_each: Option<bool>, // None = don't show, Some(value) = show with this state
1915        theme: &crate::view::theme::Theme,
1916        keybindings: &crate::input::keybindings::KeybindingResolver,
1917        hover: SearchOptionsHover,
1918    ) -> SearchOptionsLayout {
1919        use crate::primitives::display_width::str_width;
1920
1921        let mut layout = SearchOptionsLayout {
1922            row: area.y,
1923            ..Default::default()
1924        };
1925
1926        // Use menu dropdown background (dark gray) for the options bar
1927        let base_style = Style::default()
1928            .fg(theme.menu_dropdown_fg)
1929            .bg(theme.menu_dropdown_bg);
1930
1931        // Style for hovered options - use menu hover colors
1932        let hover_style = Style::default()
1933            .fg(theme.menu_hover_fg)
1934            .bg(theme.menu_hover_bg);
1935
1936        // Helper to look up keybinding for an action. The search-option toggles
1937        // live in the SearchPrompt context; fall back to Prompt then Global so a
1938        // user override in either still surfaces in the hint.
1939        let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1940            keybindings
1941                .get_keybinding_for_action(
1942                    action,
1943                    crate::input::keybindings::KeyContext::SearchPrompt,
1944                )
1945                .or_else(|| {
1946                    keybindings.get_keybinding_for_action(
1947                        action,
1948                        crate::input::keybindings::KeyContext::Prompt,
1949                    )
1950                })
1951                .or_else(|| {
1952                    keybindings.get_keybinding_for_action(
1953                        action,
1954                        crate::input::keybindings::KeyContext::Global,
1955                    )
1956                })
1957        };
1958
1959        // Get keybindings for search options
1960        let case_shortcut =
1961            get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1962        let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1963        let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1964
1965        // Build the options display with checkboxes
1966        let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1967        let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1968        let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1969
1970        // Style for active (checked) options. The toolbar already draws its
1971        // base on `menu_dropdown_*` and its hover on `menu_hover_*`, so the
1972        // checked state uses the matching `menu_active_*` pair from the same
1973        // family — a proper, theme-designed fg/bg pair rather than a mix of
1974        // keys meant for different surfaces. Its background is only a subtle
1975        // elevation over the toolbar in normal themes (Dracula: cyan on a
1976        // slate [68,71,90], not a heavy block), while high-contrast still gets
1977        // its bold accessibility highlight. The earlier `menu_highlight_fg` on
1978        // `menu_dropdown_bg` collided on Dracula (both [40,42,54]), rendering
1979        // the checked checkbox invisible.
1980        let active_style = Style::default()
1981            .fg(theme.menu_active_fg)
1982            .bg(theme.menu_active_bg);
1983
1984        // Style for keyboard shortcuts - use theme color for consistency
1985        let shortcut_style = Style::default()
1986            .fg(theme.help_separator_fg)
1987            .bg(theme.menu_dropdown_bg);
1988
1989        // Hovered shortcut style
1990        let hover_shortcut_style = Style::default()
1991            .fg(theme.menu_hover_fg)
1992            .bg(theme.menu_hover_bg);
1993
1994        let mut spans = Vec::new();
1995        let mut current_col = area.x;
1996
1997        // Left padding
1998        spans.push(Span::styled(" ", base_style));
1999        current_col += 1;
2000
2001        // Helper to get style based on hover and checked state
2002        let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
2003            if is_hovered {
2004                hover_style
2005            } else if is_checked {
2006                active_style
2007            } else {
2008                base_style
2009            }
2010        };
2011
2012        // Case Sensitive option
2013        let case_hovered = hover == SearchOptionsHover::CaseSensitive;
2014        let case_start = current_col;
2015        let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
2016        let case_shortcut_text = case_shortcut
2017            .as_ref()
2018            .map(|s| format!(" ({})", s))
2019            .unwrap_or_default();
2020        let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
2021
2022        spans.push(Span::styled(
2023            case_label,
2024            get_checkbox_style(case_hovered, case_sensitive),
2025        ));
2026        if !case_shortcut_text.is_empty() {
2027            spans.push(Span::styled(
2028                case_shortcut_text,
2029                if case_hovered {
2030                    hover_shortcut_style
2031                } else {
2032                    shortcut_style
2033                },
2034            ));
2035        }
2036        current_col += case_full_width as u16;
2037        layout.case_sensitive = Some((case_start, current_col));
2038
2039        // Separator
2040        spans.push(Span::styled("   ", base_style));
2041        current_col += 3;
2042
2043        // Whole Word option
2044        let word_hovered = hover == SearchOptionsHover::WholeWord;
2045        let word_start = current_col;
2046        let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
2047        let word_shortcut_text = word_shortcut
2048            .as_ref()
2049            .map(|s| format!(" ({})", s))
2050            .unwrap_or_default();
2051        let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
2052
2053        spans.push(Span::styled(
2054            word_label,
2055            get_checkbox_style(word_hovered, whole_word),
2056        ));
2057        if !word_shortcut_text.is_empty() {
2058            spans.push(Span::styled(
2059                word_shortcut_text,
2060                if word_hovered {
2061                    hover_shortcut_style
2062                } else {
2063                    shortcut_style
2064                },
2065            ));
2066        }
2067        current_col += word_full_width as u16;
2068        layout.whole_word = Some((word_start, current_col));
2069
2070        // Separator
2071        spans.push(Span::styled("   ", base_style));
2072        current_col += 3;
2073
2074        // Regex option
2075        let regex_hovered = hover == SearchOptionsHover::Regex;
2076        let regex_start = current_col;
2077        let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
2078        let regex_shortcut_text = regex_shortcut
2079            .as_ref()
2080            .map(|s| format!(" ({})", s))
2081            .unwrap_or_default();
2082        let regex_full_width = str_width(&regex_label) + str_width(&regex_shortcut_text);
2083
2084        spans.push(Span::styled(
2085            regex_label,
2086            get_checkbox_style(regex_hovered, use_regex),
2087        ));
2088        if !regex_shortcut_text.is_empty() {
2089            spans.push(Span::styled(
2090                regex_shortcut_text,
2091                if regex_hovered {
2092                    hover_shortcut_style
2093                } else {
2094                    shortcut_style
2095                },
2096            ));
2097        }
2098        current_col += regex_full_width as u16;
2099        layout.regex = Some((regex_start, current_col));
2100
2101        // Show capture group hint when regex is enabled in replace mode
2102        if use_regex && confirm_each.is_some() {
2103            let hint = " \u{2502} $1,$2,…";
2104            spans.push(Span::styled(hint, shortcut_style));
2105            current_col += str_width(hint) as u16;
2106        }
2107
2108        // Confirm Each option (only shown in replace mode)
2109        if let Some(confirm_value) = confirm_each {
2110            let confirm_shortcut =
2111                get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
2112            let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
2113
2114            // Separator
2115            spans.push(Span::styled("   ", base_style));
2116            current_col += 3;
2117
2118            let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
2119            let confirm_start = current_col;
2120            let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
2121            let confirm_shortcut_text = confirm_shortcut
2122                .as_ref()
2123                .map(|s| format!(" ({})", s))
2124                .unwrap_or_default();
2125            let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
2126
2127            spans.push(Span::styled(
2128                confirm_label,
2129                get_checkbox_style(confirm_hovered, confirm_value),
2130            ));
2131            if !confirm_shortcut_text.is_empty() {
2132                spans.push(Span::styled(
2133                    confirm_shortcut_text,
2134                    if confirm_hovered {
2135                        hover_shortcut_style
2136                    } else {
2137                        shortcut_style
2138                    },
2139                ));
2140            }
2141            current_col += confirm_full_width as u16;
2142            layout.confirm_each = Some((confirm_start, current_col));
2143        }
2144
2145        // Fill remaining space
2146        let current_width = (current_col - area.x) as usize;
2147        let available_width = area.width as usize;
2148        if current_width < available_width {
2149            spans.push(Span::styled(
2150                " ".repeat(available_width.saturating_sub(current_width)),
2151                base_style,
2152            ));
2153        }
2154
2155        let options_line = Paragraph::new(Line::from(spans));
2156        frame.render_widget(options_line, area);
2157
2158        layout
2159    }
2160}
2161
2162#[cfg(test)]
2163mod tests {
2164    use super::*;
2165    use std::path::PathBuf;
2166
2167    #[test]
2168    fn test_truncate_path_short_path() {
2169        let path = PathBuf::from("/home/user/project");
2170        let result = truncate_path(&path, 50);
2171
2172        assert!(!result.truncated);
2173        assert_eq!(result.suffix, "/home/user/project");
2174        assert!(result.prefix.is_empty());
2175    }
2176
2177    #[test]
2178    fn test_truncate_path_long_path() {
2179        let path = PathBuf::from(
2180            "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
2181        );
2182        let result = truncate_path(&path, 40);
2183
2184        assert!(result.truncated, "Path should be truncated");
2185        assert_eq!(result.prefix, "/private");
2186        assert!(
2187            result.suffix.contains("project_root"),
2188            "Suffix should contain project_root"
2189        );
2190    }
2191
2192    #[test]
2193    fn test_truncate_path_preserves_last_components() {
2194        let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
2195        let result = truncate_path(&path, 30);
2196
2197        assert!(result.truncated);
2198        // Should preserve the last components that fit
2199        assert!(
2200            result.suffix.contains("src"),
2201            "Should preserve last component 'src', got: {}",
2202            result.suffix
2203        );
2204    }
2205
2206    #[test]
2207    fn test_truncate_path_display_len() {
2208        let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
2209        let result = truncate_path(&path, 30);
2210
2211        // The display length should not exceed max_len (approximately)
2212        let display = result.to_string_plain();
2213        assert!(
2214            display.len() <= 35, // Allow some slack for trailing slash
2215            "Display should be truncated to around 30 chars, got {} chars: {}",
2216            display.len(),
2217            display
2218        );
2219    }
2220
2221    #[test]
2222    fn test_truncate_path_root_only() {
2223        let path = PathBuf::from("/");
2224        let result = truncate_path(&path, 50);
2225
2226        assert!(!result.truncated);
2227        assert_eq!(result.suffix, "/");
2228    }
2229
2230    #[test]
2231    fn test_truncate_path_multibyte_single_component_does_not_panic() {
2232        // Routes into the "truncate the end" branch (line 414): the prefix
2233        // alone exceeds max_len, so available_for_suffix becomes 0. Before
2234        // the fix, byte-slicing `path_str` at `max_len - 3 = 2` lands
2235        // inside the 3-byte UTF-8 sequence for `ユ` and panicked the
2236        // editor — same class as #1718.
2237        let path = PathBuf::from("/ユーザーのプロジェクト名前/file");
2238        let result = truncate_path(&path, 5);
2239        let display = result.to_string_plain();
2240        assert!(display.is_char_boundary(display.len()));
2241        assert!(display.ends_with("..."));
2242    }
2243
2244    #[test]
2245    fn test_truncate_path_multibyte_last_component_does_not_panic() {
2246        // Routes into the "truncate the last component" branch (line 453):
2247        // available_for_suffix is large enough to enter the suffix-build
2248        // loop, but the only remaining component doesn't fit, so we fall
2249        // back to truncating it. Before the fix, byte-slicing the
2250        // non-ASCII component at `truncate_to = 1` lands inside the 3-byte
2251        // UTF-8 sequence for `ユ` and panicked.
2252        let path = PathBuf::from("/a/ユーザーのプロジェクト名前");
2253        let result = truncate_path(&path, 13);
2254        let display = result.to_string_plain();
2255        assert!(display.is_char_boundary(display.len()));
2256    }
2257
2258    #[test]
2259    fn test_truncated_path_to_string_plain() {
2260        let truncated = TruncatedPath {
2261            prefix: "/home".to_string(),
2262            truncated: true,
2263            suffix: "/project/src".to_string(),
2264            sep: '/',
2265        };
2266
2267        assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
2268    }
2269
2270    #[test]
2271    fn test_truncated_path_to_string_plain_no_truncation() {
2272        let truncated = TruncatedPath {
2273            prefix: String::new(),
2274            truncated: false,
2275            suffix: "/home/user/project".to_string(),
2276            sep: '/',
2277        };
2278
2279        assert_eq!(truncated.to_string_plain(), "/home/user/project");
2280    }
2281
2282    /// A Windows-style "\"-path must middle-truncate (keeping drive +
2283    /// first dir and the tail) and render with backslashes — not fall into
2284    /// the crude end-truncation that `split('/')` forced because a
2285    /// backslash path has no '/' to split on. (We can exercise this on any
2286    /// OS because `truncate_path` works on the path *string*.)
2287    #[test]
2288    fn test_truncate_path_windows_backslashes() {
2289        let path = Path::new(r"C:\Users\me\projects\fresh\crates\editor\src\main.rs");
2290        let t = truncate_path(path, 34);
2291        assert!(t.truncated, "long backslash path should middle-truncate");
2292        assert_eq!(t.sep, '\\', "should re-join with backslashes");
2293        let shown = t.to_string_plain();
2294        assert!(
2295            shown.starts_with(r"C:\Users"),
2296            "keeps drive + first dir: {shown}"
2297        );
2298        assert!(
2299            shown.contains(r"\[...]\"),
2300            "uses a backslash ellipsis: {shown}"
2301        );
2302        assert!(shown.ends_with("main.rs"), "keeps the tail: {shown}");
2303        assert!(!shown.contains('/'), "no forward slashes leak in: {shown}");
2304        assert!(shown.len() <= 34, "respects max_len: {shown}");
2305    }
2306
2307    /// A short backslash path that fits is returned unchanged.
2308    #[test]
2309    fn test_truncate_path_windows_short_unchanged() {
2310        let path = Path::new(r"C:\a\b");
2311        let t = truncate_path(path, 80);
2312        assert!(!t.truncated);
2313        assert_eq!(t.to_string_plain(), r"C:\a\b");
2314    }
2315
2316    #[test]
2317    fn test_remote_indicator_element_kind_equality() {
2318        // Each lifecycle state produces a distinct ElementKind so the styler
2319        // can pick the right palette for Local / Connecting / Connected /
2320        // FailedAttach / Disconnected.
2321        assert_eq!(
2322            ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
2323            ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
2324        );
2325        let distinct = [
2326            RemoteIndicatorState::Local,
2327            RemoteIndicatorState::Connecting,
2328            RemoteIndicatorState::Connected,
2329            RemoteIndicatorState::FailedAttach,
2330            RemoteIndicatorState::Disconnected,
2331        ];
2332        for (i, a) in distinct.iter().enumerate() {
2333            for (j, b) in distinct.iter().enumerate() {
2334                if i == j {
2335                    continue;
2336                }
2337                assert_ne!(
2338                    ElementKind::RemoteIndicator(*a),
2339                    ElementKind::RemoteIndicator(*b),
2340                    "expected {:?} != {:?}",
2341                    a,
2342                    b
2343                );
2344            }
2345        }
2346    }
2347
2348    #[test]
2349    fn test_remote_indicator_state_default_is_local() {
2350        // `Default` → `Local` is relied on by callers that construct the
2351        // indicator before a connection is known.
2352        assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
2353    }
2354
2355    #[test]
2356    fn test_remote_indicator_override_deserializes_kind_tags() {
2357        // Pins the wire shape the `SetRemoteIndicatorState` plugin op
2358        // accepts. A breaking change here would silently reject plugin
2359        // payloads after upgrade.
2360        let cases: &[(&str, RemoteIndicatorOverride)] = &[
2361            (r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
2362            (
2363                r#"{"kind":"connecting","label":"Building"}"#,
2364                RemoteIndicatorOverride::Connecting {
2365                    label: Some("Building".into()),
2366                },
2367            ),
2368            (
2369                r#"{"kind":"connecting"}"#,
2370                RemoteIndicatorOverride::Connecting { label: None },
2371            ),
2372            (
2373                r#"{"kind":"connected","label":"Container:abc"}"#,
2374                RemoteIndicatorOverride::Connected {
2375                    label: Some("Container:abc".into()),
2376                },
2377            ),
2378            (
2379                r#"{"kind":"failed_attach","error":"exit 1"}"#,
2380                RemoteIndicatorOverride::FailedAttach {
2381                    error: Some("exit 1".into()),
2382                },
2383            ),
2384            (
2385                r#"{"kind":"disconnected","label":"Container:abc"}"#,
2386                RemoteIndicatorOverride::Disconnected {
2387                    label: Some("Container:abc".into()),
2388                },
2389            ),
2390        ];
2391        for (json, expected) in cases {
2392            let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
2393                .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
2394            assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
2395        }
2396    }
2397
2398    #[test]
2399    fn test_remote_indicator_override_labels() {
2400        // Labels surface in the `{remote}` element text directly, so
2401        // defaults matter — a missing `label` must still produce
2402        // something readable.
2403        let connecting = RemoteIndicatorOverride::Connecting { label: None };
2404        assert!(
2405            connecting.label().contains("Connecting"),
2406            "connecting default label should mention Connecting, got {:?}",
2407            connecting.label()
2408        );
2409
2410        let connecting_labeled = RemoteIndicatorOverride::Connecting {
2411            label: Some("Building".into()),
2412        };
2413        assert!(
2414            connecting_labeled.label().contains("Building"),
2415            "labeled connecting should include the label, got {:?}",
2416            connecting_labeled.label()
2417        );
2418
2419        let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
2420        assert_eq!(failed_bare.label(), "Attach failed");
2421
2422        let failed_detail = RemoteIndicatorOverride::FailedAttach {
2423            error: Some("exit 1".into()),
2424        };
2425        assert!(
2426            failed_detail.label().contains("exit 1"),
2427            "failed with error should include the error, got {:?}",
2428            failed_detail.label()
2429        );
2430    }
2431
2432    #[test]
2433    fn test_palette_and_lsp_on_use_dedicated_theme_keys() {
2434        // Repro for issue #1711: the Palette hint and the "LSP on"
2435        // indicator used distinct palettes (help-indicator and
2436        // diagnostic-info), causing the status bar's color band to
2437        // break at the far right.
2438        //
2439        // Now they're driven by dedicated theme keys whose defaults
2440        // resolve to the status-bar palette, so the bar reads as a
2441        // single continuous color out of the box, while still letting
2442        // themes override these elements independently. Off / Error
2443        // LSP states keep their vivid diagnostic palette so real
2444        // problems still pop.
2445        let theme = crate::view::theme::Theme::from_json(
2446            r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
2447        )
2448        .expect("minimal theme should parse");
2449
2450        // Defaults: dedicated keys resolve to the status-bar palette.
2451        assert_eq!(theme.status_palette_fg, theme.status_bar_fg);
2452        assert_eq!(theme.status_palette_bg, theme.status_bar_bg);
2453        assert_eq!(theme.status_lsp_on_fg, theme.status_bar_fg);
2454        assert_eq!(theme.status_lsp_on_bg, theme.status_bar_bg);
2455
2456        let palette_style = StatusBarRenderer::element_style(
2457            ElementKind::Palette,
2458            &theme,
2459            StatusBarHover::None,
2460            WarningLevel::None,
2461            LspIndicatorState::None,
2462        );
2463        assert_eq!(palette_style.fg, Some(theme.status_palette_fg));
2464        assert_eq!(palette_style.bg, Some(theme.status_palette_bg));
2465
2466        let lsp_on_style = StatusBarRenderer::element_style(
2467            ElementKind::Lsp,
2468            &theme,
2469            StatusBarHover::None,
2470            WarningLevel::None,
2471            LspIndicatorState::On,
2472        );
2473        assert_eq!(lsp_on_style.fg, Some(theme.status_lsp_on_fg));
2474        assert_eq!(lsp_on_style.bg, Some(theme.status_lsp_on_bg));
2475
2476        // Sanity: Off / Error must still differ from the status-bar
2477        // palette so they remain user-visible signals.
2478        let lsp_off_style = StatusBarRenderer::element_style(
2479            ElementKind::Lsp,
2480            &theme,
2481            StatusBarHover::None,
2482            WarningLevel::None,
2483            LspIndicatorState::Off,
2484        );
2485        assert_eq!(lsp_off_style.fg, Some(theme.status_lsp_actionable_fg));
2486        assert_eq!(lsp_off_style.bg, Some(theme.status_lsp_actionable_bg));
2487
2488        let lsp_error_style = StatusBarRenderer::element_style(
2489            ElementKind::Lsp,
2490            &theme,
2491            StatusBarHover::None,
2492            WarningLevel::None,
2493            LspIndicatorState::Error,
2494        );
2495        assert_eq!(lsp_error_style.fg, Some(theme.diagnostic_error_fg));
2496        assert_eq!(lsp_error_style.bg, Some(theme.diagnostic_error_bg));
2497    }
2498
2499    #[test]
2500    fn test_status_palette_and_lsp_on_keys_override_independently() {
2501        // A theme that only sets the new keys should produce styles
2502        // that follow the override, not the underlying status_bar_*
2503        // colors. This is the entire point of introducing dedicated
2504        // keys: themes can repaint these specific indicators without
2505        // touching the rest of the status bar.
2506        let theme_json = r#"{
2507            "name":"t",
2508            "editor":{},
2509            "ui":{
2510                "status_bar_fg":"White",
2511                "status_bar_bg":"DarkGray",
2512                "status_palette_fg":"Black",
2513                "status_palette_bg":"Yellow",
2514                "status_lsp_on_fg":"Black",
2515                "status_lsp_on_bg":"Cyan"
2516            },
2517            "search":{},
2518            "diagnostic":{},
2519            "syntax":{}
2520        }"#;
2521        let theme = crate::view::theme::Theme::from_json(theme_json).expect("theme should parse");
2522        assert_ne!(theme.status_palette_fg, theme.status_bar_fg);
2523        assert_ne!(theme.status_palette_bg, theme.status_bar_bg);
2524        assert_ne!(theme.status_lsp_on_fg, theme.status_bar_fg);
2525        assert_ne!(theme.status_lsp_on_bg, theme.status_bar_bg);
2526    }
2527
2528    #[test]
2529    fn test_status_separator_keys_default_and_override() {
2530        // The separator glyph is painted by dedicated theme keys so it can
2531        // be dimmed against the bar. By default both resolve to the
2532        // status-bar palette, keeping the bar a single continuous color.
2533        let theme = crate::view::theme::Theme::from_json(
2534            r#"{"name":"t","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#,
2535        )
2536        .expect("minimal theme should parse");
2537        assert_eq!(theme.status_separator_fg, theme.status_bar_fg);
2538        assert_eq!(theme.status_separator_bg, theme.status_bar_bg);
2539
2540        // A theme that sets only the separator keys repaints the glyph
2541        // without touching the rest of the bar.
2542        let theme = crate::view::theme::Theme::from_json(
2543            r#"{
2544                "name":"t",
2545                "editor":{},
2546                "ui":{
2547                    "status_bar_fg":"White",
2548                    "status_bar_bg":"DarkGray",
2549                    "status_separator_fg":"Gray",
2550                    "status_separator_bg":"Black"
2551                },
2552                "search":{},
2553                "diagnostic":{},
2554                "syntax":{}
2555            }"#,
2556        )
2557        .expect("theme should parse");
2558        assert_ne!(theme.status_separator_fg, theme.status_bar_fg);
2559        assert_ne!(theme.status_separator_bg, theme.status_bar_bg);
2560    }
2561
2562    #[test]
2563    fn test_remote_indicator_override_state_projection() {
2564        assert_eq!(
2565            RemoteIndicatorOverride::Local.state(),
2566            RemoteIndicatorState::Local
2567        );
2568        assert_eq!(
2569            RemoteIndicatorOverride::Connecting { label: None }.state(),
2570            RemoteIndicatorState::Connecting
2571        );
2572        assert_eq!(
2573            RemoteIndicatorOverride::Connected { label: None }.state(),
2574            RemoteIndicatorState::Connected
2575        );
2576        assert_eq!(
2577            RemoteIndicatorOverride::FailedAttach { error: None }.state(),
2578            RemoteIndicatorState::FailedAttach
2579        );
2580        assert_eq!(
2581            RemoteIndicatorOverride::Disconnected { label: None }.state(),
2582            RemoteIndicatorState::Disconnected
2583        );
2584    }
2585
2586    // Regression coverage for issue #1967 — the cursor indicator must keep
2587    // a stable rendered width as the cursor moves so the bar doesn't
2588    // shift. The helpers reserve the digit count of the buffer's total
2589    // line count for the line number and `CURSOR_COL_RESERVE` for the
2590    // column number, suffix-padding the text without altering the
2591    // numbers themselves so existing screen assertions still see
2592    // literals like "Ln 1, Col 1".
2593
2594    #[test]
2595    fn test_cursor_position_widths_stable_across_cursor_movement() {
2596        let line_count = 50;
2597        // Movement across a 50-line file (two-digit line_count) should
2598        // produce a constant rendered width regardless of cursor position.
2599        let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
2600            .into_iter()
2601            .map(|(ln, col)| format_cursor_position(ln, col, line_count).len())
2602            .collect();
2603        assert!(
2604            widths.windows(2).all(|w| w[0] == w[1]),
2605            "rendered widths drift across cursor movements: {widths:?}"
2606        );
2607    }
2608
2609    #[test]
2610    fn test_cursor_position_preserves_natural_number_text() {
2611        // The natural "Ln 1, Col 1" substring must remain intact so
2612        // existing screen-content assertions (and screen readers) keep
2613        // working. Padding is suffix-only.
2614        let text = format_cursor_position(1, 1, 50);
2615        assert!(
2616            text.starts_with("Ln 1, Col 1"),
2617            "expected text to start with natural numbers, got {text:?}"
2618        );
2619        assert!(
2620            text.ends_with(' '),
2621            "expected trailing padding, got {text:?}"
2622        );
2623    }
2624
2625    #[test]
2626    fn test_cursor_position_no_padding_for_single_line_buffer() {
2627        // For a single-line buffer the reserved line-digit width is 1,
2628        // so a small column number still produces the canonical
2629        // "Ln 1, Col 1" with reserve-only trailing padding.
2630        let text = format_cursor_position(1, 1, 1);
2631        // Min width = "Ln , Col ".len()(=9) + 1 (line_digits) + 3 (col reserve) = 13
2632        assert_eq!(text.len(), 13);
2633        assert!(text.starts_with("Ln 1, Col 1"));
2634    }
2635
2636    #[test]
2637    fn test_cursor_position_does_not_shrink_below_actual() {
2638        // When the actual numbers exceed the reserve, the rendered text
2639        // is returned unmodified (rare wide-line case).
2640        let text = format_cursor_position(99, 99999, 50);
2641        assert_eq!(text, "Ln 99, Col 99999");
2642    }
2643
2644    #[test]
2645    fn test_cursor_position_compact_widths_stable() {
2646        let line_count = 50;
2647        let widths: Vec<usize> = [(1, 1), (5, 12), (12, 5), (50, 100), (1, 1)]
2648            .into_iter()
2649            .map(|(ln, col)| format_cursor_position_compact(ln, col, line_count).len())
2650            .collect();
2651        assert!(
2652            widths.windows(2).all(|w| w[0] == w[1]),
2653            "compact widths drift across cursor movements: {widths:?}"
2654        );
2655    }
2656
2657    #[test]
2658    fn test_cursor_position_compact_preserves_natural_text() {
2659        let text = format_cursor_position_compact(1, 1, 50);
2660        assert!(
2661            text.starts_with("1:1"),
2662            "expected text to start with natural numbers, got {text:?}"
2663        );
2664    }
2665
2666    #[test]
2667    fn test_cursor_position_scales_with_line_count() {
2668        // Larger buffers reserve more line-digit width so that line
2669        // numbers at the high end of the buffer don't widen the bar.
2670        let short = format_cursor_position(1, 1, 9);
2671        let long = format_cursor_position(1, 1, 10_000);
2672        assert!(
2673            long.len() > short.len(),
2674            "wider buffers should reserve more width: {short:?} vs {long:?}"
2675        );
2676        // And the wide-buffer rendering should match what a top-of-file
2677        // line number near the buffer's high end would render to.
2678        let top = format_cursor_position(1, 1, 10_000);
2679        let high = format_cursor_position(9_999, 999, 10_000);
2680        assert_eq!(top.len(), high.len());
2681    }
2682
2683    #[test]
2684    fn test_cursor_column_counts_chars_not_bytes() {
2685        let mut buf =
2686            crate::model::buffer::TextBuffer::from_str_test("hello\ncafé résumé\nworld\n");
2687        let line_start = buf.line_start_offset(1).unwrap();
2688
2689        // 'r' starts at byte 6 ("café " = 5 chars / 6 bytes), char column 5.
2690        let col = cursor_column(&mut buf, line_start + 6);
2691        assert_eq!(
2692            col, 5,
2693            "cursor at 'r' should be column 5, not byte offset 6"
2694        );
2695
2696        // 'é' starts at byte 3 (after "caf"), column 3.
2697        let col = cursor_column(&mut buf, line_start + 3);
2698        assert_eq!(col, 3, "cursor at 'é' should be column 3");
2699
2700        // 'u' in "résumé" sits at byte 10, column 8.
2701        let col = cursor_column(&mut buf, line_start + 10);
2702        assert_eq!(col, 8, "cursor at 'u' should be column 8");
2703    }
2704
2705    #[test]
2706    fn test_cursor_column_counts_grapheme_clusters() {
2707        // Line 1 is "e + combining acute" followed by 'x'. The accented 'e' is
2708        // two code points but one grapheme; counting graphemes (not chars or
2709        // bytes) keeps the column aligned with grapheme-based cursor movement.
2710        let mut buf = crate::model::buffer::TextBuffer::from_str_test("ab\ne\u{0301}x\n");
2711        let line_start = buf.line_start_offset(1).unwrap();
2712
2713        // 'x' sits after the 1-byte 'e' and 2-byte combining accent (byte 3),
2714        // which is char column 2 but grapheme column 1.
2715        let col = cursor_column(&mut buf, line_start + 3);
2716        assert_eq!(
2717            col, 1,
2718            "accented 'e' is one grapheme; 'x' should be column 1, not 2"
2719        );
2720    }
2721
2722    #[test]
2723    fn test_cursor_column_zwj_emoji_is_one_grapheme() {
2724        // Family emoji is several code points joined by ZWJ but a single
2725        // grapheme cluster (18 bytes).
2726        let mut buf = crate::model::buffer::TextBuffer::from_str_test("👨\u{200D}👩\u{200D}👧z\n");
2727        let line_start = buf.line_start_offset(0).unwrap();
2728
2729        let col = cursor_column(&mut buf, line_start + 18);
2730        assert_eq!(col, 1, "ZWJ family emoji should count as one column");
2731    }
2732
2733    #[test]
2734    fn test_cursor_column_at_line_start_is_zero() {
2735        let mut buf = crate::model::buffer::TextBuffer::from_str_test("hello\nworld\n");
2736        let line_start = buf.line_start_offset(1).unwrap();
2737        assert_eq!(cursor_column(&mut buf, line_start), 0);
2738    }
2739}