1use crate::data_context::{DataContext, DataDep};
7use crate::theme::{Role, Style};
8use std::borrow::Cow;
9use unicode_width::UnicodeWidthStr;
10
11pub mod agent;
12pub mod builder;
13pub mod context_bar;
14pub mod context_window;
15pub mod cost;
16pub mod effort;
17pub mod extra_usage;
18pub mod git_branch;
19pub mod model;
20pub mod output_style;
21pub mod rate_limit_5h;
22pub mod rate_limit_5h_reset;
23pub mod rate_limit_7d;
24pub mod rate_limit_7d_reset;
25pub mod rate_limit_format;
26pub mod session_duration;
27pub mod tokens;
28pub mod version;
29pub mod vim;
30pub mod workspace;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
39#[non_exhaustive]
40pub struct RenderedSegment {
41 pub(crate) text: String,
42 pub(crate) width: u16,
43 pub(crate) right_separator: Option<Separator>,
44 pub(crate) style: Style,
45}
46
47impl RenderedSegment {
48 #[must_use]
54 pub fn new(text: impl Into<String>) -> Self {
55 let text = sanitize_control_chars(text.into());
56 let width = text_width(&text);
57 Self {
58 text,
59 width,
60 right_separator: None,
61 style: Style::default(),
62 }
63 }
64
65 #[must_use]
66 pub fn with_separator(text: impl Into<String>, separator: Separator) -> Self {
67 let text = sanitize_control_chars(text.into());
68 let width = text_width(&text);
69 Self {
70 text,
71 width,
72 right_separator: Some(separator),
73 style: Style::default(),
74 }
75 }
76
77 #[must_use]
86 pub fn with_role(mut self, role: Role) -> Self {
87 self.style.role = Some(role);
88 self
89 }
90
91 #[must_use]
95 pub fn with_style(mut self, style: Style) -> Self {
96 self.style = style;
97 self
98 }
99
100 #[must_use]
102 pub fn style(&self) -> &Style {
103 &self.style
104 }
105
106 #[must_use]
108 pub fn text(&self) -> &str {
109 &self.text
110 }
111
112 #[must_use]
114 pub fn width(&self) -> u16 {
115 self.width
116 }
117
118 #[must_use]
121 pub fn right_separator(&self) -> Option<&Separator> {
122 self.right_separator.as_ref()
123 }
124
125 #[must_use]
130 pub(crate) fn from_parts(
131 text: String,
132 width: u16,
133 right_separator: Option<Separator>,
134 style: Style,
135 ) -> Self {
136 Self {
137 text,
138 width,
139 right_separator,
140 style,
141 }
142 }
143}
144
145#[must_use]
147pub(crate) fn text_width(s: &str) -> u16 {
148 u16::try_from(UnicodeWidthStr::width(s)).unwrap_or(u16::MAX)
149}
150
151pub(crate) fn sanitize_control_chars(s: String) -> String {
163 if !s.chars().any(char::is_control) {
164 return s;
165 }
166 s.chars().filter(|c| !c.is_control()).collect()
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
184#[non_exhaustive]
185pub enum Separator {
186 Space,
187 Theme,
188 Literal(Cow<'static, str>),
189 Powerline { width: PowerlineWidth },
190 None,
191}
192
193#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
199pub enum PowerlineWidth {
200 #[default]
201 One,
202 Two,
203}
204
205impl PowerlineWidth {
206 #[must_use]
208 pub const fn cells(self) -> u16 {
209 match self {
210 Self::One => 1,
211 Self::Two => 2,
212 }
213 }
214}
215
216const POWERLINE_CHEVRON_PADDED: &str = " \u{E0B0} ";
219
220impl Separator {
221 #[must_use]
226 pub const fn powerline() -> Self {
227 Self::Powerline {
228 width: PowerlineWidth::One,
229 }
230 }
231
232 #[must_use]
233 pub fn text(&self) -> &str {
234 match self {
235 Self::Space | Self::Theme => " ",
236 Self::Literal(s) => s,
237 Self::Powerline { .. } => POWERLINE_CHEVRON_PADDED,
238 Self::None => "",
239 }
240 }
241
242 #[must_use]
243 pub fn width(&self) -> u16 {
244 match self {
245 Self::Space | Self::Theme => 1,
246 Self::Literal(s) => text_width(s),
247 Self::Powerline { width } => width.cells() + 2,
248 Self::None => 0,
249 }
250 }
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub struct WidthBounds {
256 min: u16,
257 max: u16,
258}
259
260impl WidthBounds {
261 #[must_use]
263 pub fn new(min: u16, max: u16) -> Option<Self> {
264 (min <= max).then_some(Self { min, max })
265 }
266
267 #[must_use]
268 pub fn min(self) -> u16 {
269 self.min
270 }
271
272 #[must_use]
273 pub fn max(self) -> u16 {
274 self.max
275 }
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
285#[non_exhaustive]
286pub struct SegmentDefaults {
287 pub priority: u8,
288 pub width: Option<WidthBounds>,
289 pub default_separator: Separator,
290 pub truncatable: bool,
297}
298
299impl SegmentDefaults {
300 #[must_use]
304 pub fn with_priority(priority: u8) -> Self {
305 Self {
306 priority,
307 ..Self::default()
308 }
309 }
310
311 #[must_use]
313 pub fn with_width(mut self, bounds: WidthBounds) -> Self {
314 self.width = Some(bounds);
315 self
316 }
317
318 #[must_use]
320 pub fn with_default_separator(mut self, separator: Separator) -> Self {
321 self.default_separator = separator;
322 self
323 }
324
325 #[must_use]
327 pub fn with_truncatable(mut self, truncatable: bool) -> Self {
328 self.truncatable = truncatable;
329 self
330 }
331}
332
333impl Default for SegmentDefaults {
334 fn default() -> Self {
335 Self {
336 priority: 128,
337 width: None,
338 default_separator: Separator::Space,
339 truncatable: false,
340 }
341 }
342}
343
344pub type RenderResult = Result<Option<RenderedSegment>, SegmentError>;
356
357#[derive(Debug)]
362#[non_exhaustive]
363pub struct SegmentError {
364 pub message: String,
365 pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
366}
367
368impl SegmentError {
369 #[must_use]
370 pub fn new(message: impl Into<String>) -> Self {
371 Self {
372 message: message.into(),
373 source: None,
374 }
375 }
376
377 #[must_use]
378 pub fn with_source(
379 message: impl Into<String>,
380 source: Box<dyn std::error::Error + Send + Sync>,
381 ) -> Self {
382 Self {
383 message: message.into(),
384 source: Some(source),
385 }
386 }
387}
388
389impl std::fmt::Display for SegmentError {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 f.write_str(&self.message)?;
392 if let Some(src) = &self.source {
393 write!(f, ": {src}")?;
394 }
395 Ok(())
396 }
397}
398
399impl std::error::Error for SegmentError {
400 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
401 self.source.as_deref().map(|e| e as &dyn std::error::Error)
402 }
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416#[non_exhaustive]
417pub struct RenderContext {
418 pub terminal_width: u16,
421}
422
423impl RenderContext {
424 #[must_use]
425 pub fn new(terminal_width: u16) -> Self {
426 Self { terminal_width }
427 }
428}
429
430pub trait Segment: Send {
431 fn render(&self, ctx: &DataContext, rc: &RenderContext) -> RenderResult;
442
443 #[allow(unused_variables)]
463 #[must_use]
464 fn shrink_to_fit(
465 &self,
466 ctx: &DataContext,
467 rc: &RenderContext,
468 target: u16,
469 ) -> Option<RenderedSegment> {
470 None
471 }
472
473 #[must_use]
487 fn data_deps(&self) -> &'static [DataDep] {
488 &[DataDep::Status]
489 }
490
491 #[must_use]
494 fn defaults(&self) -> SegmentDefaults {
495 SegmentDefaults::default()
496 }
497}
498
499pub const DEFAULT_SEGMENT_IDS: &[&str] = &[
507 "model",
508 "context_window",
509 "cost",
510 "effort",
511 "git_branch",
512 "workspace",
513];
514
515pub const BUILT_IN_SEGMENT_IDS: &[&str] = &[
521 "model",
522 "context_window",
523 "context_bar",
524 "workspace",
525 "cost",
526 "effort",
527 "output_style",
528 "vim",
529 "agent",
530 "git_branch",
531 "rate_limit_5h",
532 "rate_limit_7d",
533 "rate_limit_5h_reset",
534 "rate_limit_7d_reset",
535 "extra_usage",
536 "session_duration",
537 "tokens_input",
538 "tokens_output",
539 "tokens_cached",
540 "tokens_total",
541 "version",
542];
543
544#[must_use]
551pub fn built_in_by_id(
552 id: &str,
553 extras: Option<&std::collections::BTreeMap<String, toml::Value>>,
554 warn: &mut impl FnMut(&str),
555) -> Option<Box<dyn Segment>> {
556 let empty: std::collections::BTreeMap<String, toml::Value> = std::collections::BTreeMap::new();
557 let e = extras.unwrap_or(&empty);
558 match id {
559 "model" => Some(Box::new(model::ModelSegment::from_extras(e, warn))),
560 "context_window" => Some(Box::new(context_window::ContextWindowSegment)),
561 "context_bar" => Some(Box::new(context_bar::ContextBarSegment::from_extras(
562 e, warn,
563 ))),
564 "workspace" => Some(Box::new(workspace::WorkspaceSegment)),
565 "cost" => Some(Box::new(cost::CostSegment)),
566 "effort" => Some(Box::new(effort::EffortSegment)),
567 "output_style" => Some(Box::new(output_style::OutputStyleSegment)),
568 "vim" => Some(Box::new(vim::VimSegment)),
569 "agent" => Some(Box::new(agent::AgentSegment)),
570 "git_branch" => Some(Box::new(git_branch::GitBranchSegment::from_extras(e, warn))),
571 "rate_limit_5h" => Some(Box::new(rate_limit_5h::RateLimit5hSegment::from_extras(
572 e, warn,
573 ))),
574 "rate_limit_7d" => Some(Box::new(rate_limit_7d::RateLimit7dSegment::from_extras(
575 e, warn,
576 ))),
577 "rate_limit_5h_reset" => Some(Box::new(
578 rate_limit_5h_reset::RateLimit5hResetSegment::from_extras(e, warn),
579 )),
580 "rate_limit_7d_reset" => Some(Box::new(
581 rate_limit_7d_reset::RateLimit7dResetSegment::from_extras(e, warn),
582 )),
583 "extra_usage" => Some(Box::new(extra_usage::ExtraUsageSegment::from_extras(
584 e, warn,
585 ))),
586 "session_duration" => Some(Box::new(session_duration::SessionDurationSegment)),
587 "tokens_input" => Some(Box::new(tokens::TokensInputSegment)),
588 "tokens_output" => Some(Box::new(tokens::TokensOutputSegment)),
589 "tokens_cached" => Some(Box::new(tokens::TokensCachedSegment)),
590 "tokens_total" => Some(Box::new(tokens::TokensTotalSegment)),
591 "version" => Some(Box::new(version::VersionSegment::from_extras(e, warn))),
592 _ => None,
593 }
594}
595
596pub struct OverriddenSegment {
600 inner: Box<dyn Segment>,
601 priority: Option<u8>,
602 width: Option<WidthBounds>,
603 default_separator: Option<Separator>,
604 user_style: Option<Style>,
605}
606
607impl OverriddenSegment {
608 #[must_use]
609 pub fn new(inner: Box<dyn Segment>) -> Self {
610 Self {
611 inner,
612 priority: None,
613 width: None,
614 default_separator: None,
615 user_style: None,
616 }
617 }
618
619 #[must_use]
620 pub fn with_priority(mut self, priority: u8) -> Self {
621 self.priority = Some(priority);
622 self
623 }
624
625 #[must_use]
626 pub fn with_width(mut self, bounds: WidthBounds) -> Self {
627 self.width = Some(bounds);
628 self
629 }
630
631 #[must_use]
632 pub fn with_default_separator(mut self, separator: Separator) -> Self {
633 self.default_separator = Some(separator);
634 self
635 }
636
637 #[must_use]
640 pub fn with_user_style(mut self, style: Style) -> Self {
641 self.user_style = Some(style);
642 self
643 }
644}
645
646impl Segment for OverriddenSegment {
647 fn render(&self, ctx: &DataContext, rc: &RenderContext) -> RenderResult {
648 let result = self.inner.render(ctx, rc)?;
649 Ok(result.map(|r| {
650 let r = apply_separator_override(r, self.default_separator.as_ref());
651 match &self.user_style {
652 Some(override_style) => {
653 let merged = merge_user_override(r.style(), override_style);
654 r.with_style(merged)
655 }
656 None => r,
657 }
658 }))
659 }
660
661 fn shrink_to_fit(
662 &self,
663 ctx: &DataContext,
664 rc: &RenderContext,
665 target: u16,
666 ) -> Option<RenderedSegment> {
667 let inner = self.inner.shrink_to_fit(ctx, rc, target)?;
668 let inner = apply_separator_override(inner, self.default_separator.as_ref());
669 Some(match &self.user_style {
670 Some(override_style) => {
671 let merged = merge_user_override(inner.style(), override_style);
672 inner.with_style(merged)
673 }
674 None => inner,
675 })
676 }
677
678 fn data_deps(&self) -> &'static [DataDep] {
679 self.inner.data_deps()
680 }
681
682 fn defaults(&self) -> SegmentDefaults {
683 let mut d = self.inner.defaults();
684 if let Some(p) = self.priority {
685 d.priority = p;
686 }
687 if let Some(w) = self.width {
688 d.width = Some(w);
689 }
690 if let Some(sep) = self.default_separator.clone() {
691 d.default_separator = sep;
692 }
693 d
694 }
695}
696
697fn apply_separator_override(
707 r: RenderedSegment,
708 sep_override: Option<&Separator>,
709) -> RenderedSegment {
710 let Some(override_sep) = sep_override else {
711 return r;
712 };
713 let should_replace = matches!(
714 r.right_separator.as_ref(),
715 Some(Separator::Space) | Some(Separator::Theme)
716 );
717 if !should_replace {
718 return r;
719 }
720 let mut r = r;
721 r.right_separator = Some(override_sep.clone());
722 r
723}
724
725fn merge_user_override(inner: &Style, override_style: &Style) -> Style {
735 let mut merged = override_style.clone();
736 if merged.hyperlink.is_none() {
737 merged.hyperlink = inner.hyperlink.clone();
738 }
739 merged
740}
741
742#[cfg(test)]
743mod layout_type_tests {
744 use super::*;
745
746 #[test]
747 fn rendered_segment_computes_width() {
748 let r = RenderedSegment::new("hello");
749 assert_eq!(r.text(), "hello");
750 assert_eq!(r.width(), 5);
751 assert_eq!(r.right_separator(), None);
752 }
753
754 #[test]
755 fn rendered_segment_counts_cells_not_bytes_for_middle_dot() {
756 let r = RenderedSegment::new("42% · 200k");
758 assert_eq!(r.width(), 10);
759 }
760
761 #[test]
762 fn rendered_segment_strips_csi_clear_screen_injection() {
763 let r = RenderedSegment::new("evil\x1b[2J");
765 assert_eq!(r.text(), "evil[2J");
766 assert_eq!(r.width(), 7);
767 assert!(!r.text().contains('\x1b'));
768 }
769
770 #[test]
771 fn rendered_segment_strips_osc_set_title_with_bel_terminator() {
772 let r = RenderedSegment::new("\x1b]0;pwn\x07rest");
775 assert_eq!(r.text(), "]0;pwnrest");
776 assert!(!r.text().contains('\x1b'));
777 assert!(!r.text().contains('\x07'));
778 }
779
780 #[test]
781 fn rendered_segment_strips_common_c0_controls() {
782 let r = RenderedSegment::new("a\x07b\x08c\td\ne\rf");
783 assert_eq!(r.text(), "abcdef");
784 assert_eq!(r.width(), 6);
785 }
786
787 #[test]
788 fn rendered_segment_strips_c1_controls_and_del() {
789 let r = RenderedSegment::new("x\u{007F}y\u{0085}z\u{009B}");
790 assert_eq!(r.text(), "xyz");
791 assert_eq!(r.width(), 3);
792 }
793
794 #[test]
795 fn rendered_segment_preserves_unicode_without_controls() {
796 let r = RenderedSegment::new("café · 日本語");
797 assert_eq!(r.text(), "café · 日本語");
798 }
799
800 #[test]
801 fn rendered_segment_empty_string_stays_empty() {
802 let r = RenderedSegment::new("");
803 assert_eq!(r.text(), "");
804 assert_eq!(r.width(), 0);
805 }
806
807 #[test]
808 fn rendered_segment_all_control_input_collapses_to_empty() {
809 let r = RenderedSegment::new("\x1b\x07\n\t");
812 assert_eq!(r.text(), "");
813 assert_eq!(r.width(), 0);
814 }
815
816 #[test]
817 fn rendered_segment_with_separator_also_strips_controls() {
818 let r = RenderedSegment::with_separator("hi\x1bthere", Separator::None);
819 assert_eq!(r.text(), "hithere");
820 assert_eq!(r.width(), 7);
821 }
822
823 #[test]
824 fn rendered_segment_with_separator_exposes_override() {
825 let r = RenderedSegment::with_separator("x", Separator::None);
826 assert_eq!(r.right_separator(), Some(&Separator::None));
827 }
828
829 #[test]
830 fn separator_widths_match_expected() {
831 assert_eq!(Separator::Space.width(), 1);
832 assert_eq!(Separator::Theme.width(), 1);
833 assert_eq!(Separator::None.width(), 0);
834 assert_eq!(Separator::Literal(Cow::Borrowed(" | ")).width(), 3);
835 assert_eq!(Separator::powerline().width(), 3);
840 assert_eq!(
841 Separator::Powerline {
842 width: PowerlineWidth::Two,
843 }
844 .width(),
845 4
846 );
847 }
848
849 #[test]
850 fn width_bounds_rejects_inverted_range() {
851 assert!(WidthBounds::new(20, 10).is_none());
852 assert!(WidthBounds::new(10, 10).is_some());
853 assert!(WidthBounds::new(0, u16::MAX).is_some());
854 }
855
856 #[test]
857 fn segment_defaults_default_priority_is_128() {
858 assert_eq!(SegmentDefaults::default().priority, 128);
859 }
860
861 #[test]
862 fn with_priority_preserves_other_defaults() {
863 let d = SegmentDefaults::with_priority(64);
864 assert_eq!(d.priority, 64);
865 assert_eq!(d.width, None);
866 assert_eq!(d.default_separator, Separator::Space);
867 }
868
869 #[test]
870 fn builders_chain_on_segment_defaults() {
871 let bounds = WidthBounds::new(4, 40).expect("valid bounds");
872 let d = SegmentDefaults::with_priority(32)
873 .with_width(bounds)
874 .with_default_separator(Separator::Literal(Cow::Borrowed(" | ")));
875 assert_eq!(d.priority, 32);
876 assert_eq!(d.width, Some(bounds));
877 assert_eq!(
878 d.default_separator,
879 Separator::Literal(Cow::Borrowed(" | "))
880 );
881 }
882
883 #[test]
884 fn segment_error_display_includes_message_only_without_source() {
885 let err = SegmentError::new("missing rate_limits field");
886 assert_eq!(err.to_string(), "missing rate_limits field");
887 }
888
889 #[test]
890 fn segment_error_display_chains_source() {
891 let src = std::io::Error::new(std::io::ErrorKind::NotFound, "cache.json");
892 let err = SegmentError::with_source("cache read failed", Box::new(src));
893 let rendered = err.to_string();
894 assert!(rendered.starts_with("cache read failed: "));
895 assert!(rendered.contains("cache.json"));
896 }
897
898 #[test]
899 fn segment_error_source_chain_is_walkable() {
900 use std::error::Error;
901 let src = std::io::Error::other("inner");
902 let err = SegmentError::with_source("outer", Box::new(src));
903 let source = err.source().expect("source present");
904 assert_eq!(source.to_string(), "inner");
905 }
906
907 #[test]
910 fn built_in_by_id_resolves_every_default_segment() {
911 for id in DEFAULT_SEGMENT_IDS {
912 assert!(
913 built_in_by_id(id, None, &mut |_| {}).is_some(),
914 "expected built-in registry to know {id}"
915 );
916 }
917 }
918
919 #[test]
920 fn built_in_by_id_resolves_additional_documented_ids() {
921 for id in [
922 "context_bar",
923 "session_duration",
924 "rate_limit_5h",
925 "rate_limit_7d",
926 "rate_limit_5h_reset",
927 "rate_limit_7d_reset",
928 "extra_usage",
929 "tokens_input",
930 "tokens_output",
931 "tokens_cached",
932 "tokens_total",
933 "output_style",
934 "vim",
935 "agent",
936 ] {
937 assert!(
938 built_in_by_id(id, None, &mut |_| {}).is_some(),
939 "expected {id} to resolve"
940 );
941 }
942 }
943
944 #[test]
945 fn built_in_by_id_resolves_every_id_in_built_in_segment_ids() {
946 for id in BUILT_IN_SEGMENT_IDS {
950 assert!(
951 built_in_by_id(id, None, &mut |_| {}).is_some(),
952 "BUILT_IN_SEGMENT_IDS lists {id} but built_in_by_id can't construct it"
953 );
954 }
955 }
956
957 #[test]
958 fn built_in_by_id_rejects_unknown() {
959 assert!(built_in_by_id("nope", None, &mut |_| {}).is_none());
960 assert!(built_in_by_id("", None, &mut |_| {}).is_none());
961 }
962
963 #[test]
964 fn built_in_by_id_threads_extras_to_version_segment() {
965 use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
970 use std::collections::BTreeMap;
971 use std::path::PathBuf;
972 use std::sync::Arc;
973
974 let mut extras = BTreeMap::new();
975 extras.insert("prefix".to_string(), toml::Value::String("CC ".to_string()));
976 let seg = built_in_by_id("version", Some(&extras), &mut |_| {})
977 .expect("version segment resolves");
978
979 let ctx = DataContext::new(StatusContext {
980 tool: Tool::ClaudeCode,
981 model: Some(ModelInfo {
982 display_name: "X".into(),
983 }),
984 workspace: Some(WorkspaceInfo {
985 project_dir: PathBuf::from("/r"),
986 git_worktree: None,
987 }),
988 context_window: None,
989 cost: None,
990 effort: None,
991 vim: None,
992 output_style: None,
993 agent_name: None,
994 version: Some("2.1.90".into()),
995 raw: Arc::new(serde_json::Value::Null),
996 });
997 let rc = RenderContext::new(80);
998 let rendered = seg.render(&ctx, &rc).unwrap().expect("renders");
999 assert_eq!(rendered.text(), "CC 2.1.90");
1000 }
1001
1002 #[test]
1005 fn overridden_segment_replaces_priority() {
1006 let base = built_in_by_id("workspace", None, &mut |_| {}).expect("known id");
1007 let base_priority = base.defaults().priority;
1008 let wrapped = OverriddenSegment::new(base).with_priority(200);
1009 assert_eq!(wrapped.defaults().priority, 200);
1010 assert_ne!(wrapped.defaults().priority, base_priority);
1011 }
1012
1013 #[test]
1014 fn overridden_segment_replaces_width_bounds() {
1015 let base = built_in_by_id("workspace", None, &mut |_| {}).expect("known id");
1016 assert_eq!(base.defaults().width, None);
1017 let bounds = WidthBounds::new(5, 40).expect("valid");
1018 let wrapped = OverriddenSegment::new(base).with_width(bounds);
1019 assert_eq!(wrapped.defaults().width, Some(bounds));
1020 }
1021
1022 #[test]
1023 fn overridden_segment_replaces_default_separator() {
1024 let base = built_in_by_id("workspace", None, &mut |_| {}).expect("known id");
1025 let wrapped = OverriddenSegment::new(base).with_default_separator(Separator::None);
1026 assert_eq!(wrapped.defaults().default_separator, Separator::None);
1027 }
1028
1029 #[test]
1030 fn overridden_segment_delegates_render_to_inner() {
1031 let wrapped =
1032 OverriddenSegment::new(built_in_by_id("workspace", None, &mut |_| {}).unwrap())
1033 .with_priority(0);
1034 let rendered = wrapped
1035 .render(&stub_ctx(), &stub_rc())
1036 .unwrap()
1037 .expect("rendered");
1038 assert_eq!(rendered.text(), "linesmith");
1039 }
1040
1041 #[test]
1042 fn style_override_wholesale_replaces_inner_declared_style() {
1043 struct Styled;
1046 impl Segment for Styled {
1047 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1048 Ok(Some(
1049 RenderedSegment::new("x")
1050 .with_role(Role::Accent)
1051 .with_style(Style {
1052 bold: true,
1053 ..Style::default()
1054 }),
1055 ))
1056 }
1057 fn defaults(&self) -> SegmentDefaults {
1058 SegmentDefaults::with_priority(0)
1059 }
1060 }
1061 let override_style = Style {
1062 role: Some(Role::Primary),
1063 italic: true,
1064 ..Style::default()
1065 };
1066 let wrapped =
1067 OverriddenSegment::new(Box::new(Styled)).with_user_style(override_style.clone());
1068 let rendered = wrapped
1069 .render(&stub_ctx(), &stub_rc())
1070 .unwrap()
1071 .expect("rendered");
1072 assert_eq!(rendered.style, override_style);
1073 }
1074
1075 #[test]
1076 fn user_style_override_preserves_inner_hyperlink() {
1077 struct Linked;
1084 impl Segment for Linked {
1085 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1086 Ok(Some(RenderedSegment::new("x").with_style(
1087 Style::default().with_hyperlink("https://example.com"),
1088 )))
1089 }
1090 fn defaults(&self) -> SegmentDefaults {
1091 SegmentDefaults::with_priority(0)
1092 }
1093 }
1094 let override_style = Style::role(Role::Error);
1095 let wrapped =
1096 OverriddenSegment::new(Box::new(Linked)).with_user_style(override_style.clone());
1097 let rendered = wrapped
1098 .render(&stub_ctx(), &stub_rc())
1099 .unwrap()
1100 .expect("rendered");
1101 assert_eq!(rendered.style.role, Some(Role::Error));
1102 assert_eq!(
1103 rendered.style.hyperlink.as_deref(),
1104 Some("https://example.com"),
1105 );
1106 }
1107
1108 #[test]
1109 fn style_override_preserves_inner_none_return() {
1110 struct Hidden;
1111 impl Segment for Hidden {
1112 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1113 Ok(None)
1114 }
1115 fn defaults(&self) -> SegmentDefaults {
1116 SegmentDefaults::with_priority(0)
1117 }
1118 }
1119 let wrapped =
1120 OverriddenSegment::new(Box::new(Hidden)).with_user_style(Style::role(Role::Primary));
1121 assert_eq!(wrapped.render(&stub_ctx(), &stub_rc()).unwrap(), None);
1122 }
1123
1124 #[test]
1125 fn shrink_to_fit_passthrough_reaches_inner_with_user_style_applied() {
1126 struct Shrinkable;
1133 impl Segment for Shrinkable {
1134 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1135 Ok(Some(RenderedSegment::new("full")))
1136 }
1137 fn shrink_to_fit(
1138 &self,
1139 _: &DataContext,
1140 _: &RenderContext,
1141 target: u16,
1142 ) -> Option<RenderedSegment> {
1143 let r = RenderedSegment::new("c");
1144 (r.width <= target).then_some(r)
1145 }
1146 }
1147 let override_style = Style {
1148 role: Some(Role::Primary),
1149 italic: true,
1150 ..Style::default()
1151 };
1152 let wrapped =
1153 OverriddenSegment::new(Box::new(Shrinkable)).with_user_style(override_style.clone());
1154 let shrunk = wrapped
1155 .shrink_to_fit(&stub_ctx(), &stub_rc(), 5)
1156 .expect("inner returned compact form");
1157 assert_eq!(shrunk.text, "c");
1158 assert_eq!(shrunk.style, override_style);
1159 }
1160
1161 #[test]
1162 fn shrink_to_fit_passthrough_keeps_inner_style_when_no_user_override() {
1163 struct ShrinkableWithRole;
1170 impl Segment for ShrinkableWithRole {
1171 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1172 Ok(Some(RenderedSegment::new("full").with_role(Role::Accent)))
1173 }
1174 fn shrink_to_fit(
1175 &self,
1176 _: &DataContext,
1177 _: &RenderContext,
1178 _target: u16,
1179 ) -> Option<RenderedSegment> {
1180 Some(RenderedSegment::new("c").with_role(Role::Accent))
1181 }
1182 }
1183 let wrapped = OverriddenSegment::new(Box::new(ShrinkableWithRole));
1185 let shrunk = wrapped
1186 .shrink_to_fit(&stub_ctx(), &stub_rc(), 10)
1187 .expect("inner returned compact form");
1188 assert_eq!(shrunk.style.role, Some(Role::Accent));
1189 }
1190
1191 #[test]
1192 fn shrink_to_fit_passthrough_returns_none_when_inner_declines() {
1193 struct Plain;
1196 impl Segment for Plain {
1197 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1198 Ok(Some(RenderedSegment::new("plain")))
1199 }
1200 }
1201 let wrapped =
1202 OverriddenSegment::new(Box::new(Plain)).with_user_style(Style::role(Role::Primary));
1203 assert!(wrapped
1204 .shrink_to_fit(&stub_ctx(), &stub_rc(), 100)
1205 .is_none());
1206 }
1207
1208 #[test]
1209 fn shrink_to_fit_applies_separator_override_to_runtime_space() {
1210 struct ShrinkableWithRuntimeSpace;
1218 impl Segment for ShrinkableWithRuntimeSpace {
1219 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1220 Ok(Some(RenderedSegment::with_separator(
1221 "full",
1222 Separator::Space,
1223 )))
1224 }
1225 fn shrink_to_fit(
1226 &self,
1227 _: &DataContext,
1228 _: &RenderContext,
1229 _target: u16,
1230 ) -> Option<RenderedSegment> {
1231 Some(RenderedSegment::with_separator("c", Separator::Space))
1232 }
1233 }
1234 let wrapped = OverriddenSegment::new(Box::new(ShrinkableWithRuntimeSpace))
1235 .with_default_separator(Separator::powerline());
1236 let shrunk = wrapped
1237 .shrink_to_fit(&stub_ctx(), &stub_rc(), 5)
1238 .expect("inner returned compact form");
1239 assert_eq!(shrunk.right_separator(), Some(&Separator::powerline()));
1240 }
1241
1242 #[test]
1243 fn apply_separator_override_replaces_runtime_theme() {
1244 let r = RenderedSegment::with_separator("x", Separator::Theme);
1248 let out = apply_separator_override(r, Some(&Separator::powerline()));
1249 assert_eq!(out.right_separator(), Some(&Separator::powerline()));
1250 }
1251
1252 #[test]
1253 fn apply_separator_override_passes_through_when_runtime_separator_is_none() {
1254 let r = RenderedSegment::new("x"); let out = apply_separator_override(r, Some(&Separator::powerline()));
1259 assert_eq!(out.right_separator(), None);
1260 }
1261
1262 #[test]
1263 fn apply_separator_override_preserves_explicit_runtime_none() {
1264 let r = RenderedSegment::with_separator("x", Separator::None);
1268 let out = apply_separator_override(r, Some(&Separator::powerline()));
1269 assert_eq!(out.right_separator(), Some(&Separator::None));
1270 }
1271
1272 #[test]
1273 fn apply_separator_override_passes_through_when_no_override() {
1274 for runtime_sep in [
1278 None,
1279 Some(Separator::Space),
1280 Some(Separator::Theme),
1281 Some(Separator::None),
1282 Some(Separator::powerline()),
1283 ] {
1284 let r = match &runtime_sep {
1285 None => RenderedSegment::new("x"),
1286 Some(s) => RenderedSegment::with_separator("x", s.clone()),
1287 };
1288 let out = apply_separator_override(r, None);
1289 assert_eq!(out.right_separator(), runtime_sep.as_ref());
1290 }
1291 }
1292
1293 fn stub_ctx() -> DataContext {
1294 use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
1295 use std::path::PathBuf;
1296 use std::sync::Arc;
1297 DataContext::new(StatusContext {
1298 tool: Tool::ClaudeCode,
1299 model: Some(ModelInfo {
1300 display_name: "Claude".into(),
1301 }),
1302 workspace: Some(WorkspaceInfo {
1303 project_dir: PathBuf::from("/repo/linesmith"),
1304 git_worktree: None,
1305 }),
1306 context_window: None,
1307 cost: None,
1308 effort: None,
1309 vim: None,
1310 output_style: None,
1311 agent_name: None,
1312 version: None,
1313 raw: Arc::new(serde_json::Value::Null),
1314 })
1315 }
1316
1317 fn stub_rc() -> RenderContext {
1318 RenderContext::new(80)
1319 }
1320}