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