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 extras;
19pub mod git_branch;
20pub mod model;
21pub mod output_style;
22pub mod rate_limit;
23pub mod session_duration;
24pub mod tokens;
25pub mod version;
26pub mod vim;
27pub mod workspace;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct RenderedSegment {
38 pub(crate) text: String,
39 pub(crate) width: u16,
40 pub(crate) right_separator: Option<Separator>,
41 pub(crate) style: Style,
42}
43
44impl RenderedSegment {
45 #[must_use]
51 pub fn new(text: impl Into<String>) -> Self {
52 let text = sanitize_control_chars(text.into());
53 let width = text_width(&text);
54 Self {
55 text,
56 width,
57 right_separator: None,
58 style: Style::default(),
59 }
60 }
61
62 #[must_use]
63 pub fn with_separator(text: impl Into<String>, separator: Separator) -> Self {
64 let text = sanitize_control_chars(text.into());
65 let width = text_width(&text);
66 Self {
67 text,
68 width,
69 right_separator: Some(separator),
70 style: Style::default(),
71 }
72 }
73
74 #[must_use]
83 pub fn with_role(mut self, role: Role) -> Self {
84 self.style.role = Some(role);
85 self
86 }
87
88 #[must_use]
92 pub fn with_style(mut self, style: Style) -> Self {
93 self.style = style;
94 self
95 }
96
97 #[must_use]
99 pub fn style(&self) -> &Style {
100 &self.style
101 }
102
103 #[must_use]
105 pub fn text(&self) -> &str {
106 &self.text
107 }
108
109 #[must_use]
111 pub fn width(&self) -> u16 {
112 self.width
113 }
114
115 #[must_use]
118 pub fn right_separator(&self) -> Option<&Separator> {
119 self.right_separator.as_ref()
120 }
121
122 #[must_use]
127 pub(crate) fn from_parts(
128 text: String,
129 width: u16,
130 right_separator: Option<Separator>,
131 style: Style,
132 ) -> Self {
133 Self {
134 text,
135 width,
136 right_separator,
137 style,
138 }
139 }
140}
141
142#[must_use]
144pub(crate) fn text_width(s: &str) -> u16 {
145 u16::try_from(UnicodeWidthStr::width(s)).unwrap_or(u16::MAX)
146}
147
148pub(crate) fn sanitize_control_chars(s: String) -> String {
160 if !s.chars().any(char::is_control) {
161 return s;
162 }
163 s.chars().filter(|c| !c.is_control()).collect()
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
181#[non_exhaustive]
182pub enum Separator {
183 Space,
184 Theme,
185 Literal(Cow<'static, str>),
186 Powerline { width: PowerlineWidth },
187 None,
188}
189
190#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
196pub enum PowerlineWidth {
197 #[default]
198 One,
199 Two,
200}
201
202impl PowerlineWidth {
203 #[must_use]
205 pub const fn cells(self) -> u16 {
206 match self {
207 Self::One => 1,
208 Self::Two => 2,
209 }
210 }
211}
212
213const POWERLINE_CHEVRON_PADDED: &str = " \u{E0B0} ";
216
217impl Separator {
218 #[must_use]
223 pub const fn powerline() -> Self {
224 Self::Powerline {
225 width: PowerlineWidth::One,
226 }
227 }
228
229 #[must_use]
230 pub fn text(&self) -> &str {
231 match self {
232 Self::Space | Self::Theme => " ",
233 Self::Literal(s) => s,
234 Self::Powerline { .. } => POWERLINE_CHEVRON_PADDED,
235 Self::None => "",
236 }
237 }
238
239 #[must_use]
240 pub fn width(&self) -> u16 {
241 match self {
242 Self::Space | Self::Theme => 1,
243 Self::Literal(s) => text_width(s),
244 Self::Powerline { width } => width.cells() + 2,
245 Self::None => 0,
246 }
247 }
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub struct WidthBounds {
253 min: u16,
254 max: u16,
255}
256
257impl WidthBounds {
258 #[must_use]
260 pub fn new(min: u16, max: u16) -> Option<Self> {
261 (min <= max).then_some(Self { min, max })
262 }
263
264 #[must_use]
265 pub fn min(self) -> u16 {
266 self.min
267 }
268
269 #[must_use]
270 pub fn max(self) -> u16 {
271 self.max
272 }
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282#[non_exhaustive]
283pub struct SegmentDefaults {
284 pub priority: u8,
285 pub width: Option<WidthBounds>,
286 pub truncatable: bool,
293}
294
295impl SegmentDefaults {
296 #[must_use]
300 pub fn with_priority(priority: u8) -> Self {
301 Self {
302 priority,
303 ..Self::default()
304 }
305 }
306
307 #[must_use]
309 pub fn with_width(mut self, bounds: WidthBounds) -> Self {
310 self.width = Some(bounds);
311 self
312 }
313
314 #[must_use]
316 pub fn with_truncatable(mut self, truncatable: bool) -> Self {
317 self.truncatable = truncatable;
318 self
319 }
320}
321
322impl Default for SegmentDefaults {
323 fn default() -> Self {
324 Self {
325 priority: 128,
326 width: None,
327 truncatable: false,
328 }
329 }
330}
331
332pub type RenderResult = Result<Option<RenderedSegment>, SegmentError>;
344
345#[derive(Debug)]
350#[non_exhaustive]
351pub struct SegmentError {
352 pub message: String,
353 pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
354}
355
356impl SegmentError {
357 #[must_use]
358 pub fn new(message: impl Into<String>) -> Self {
359 Self {
360 message: message.into(),
361 source: None,
362 }
363 }
364
365 #[must_use]
366 pub fn with_source(
367 message: impl Into<String>,
368 source: Box<dyn std::error::Error + Send + Sync>,
369 ) -> Self {
370 Self {
371 message: message.into(),
372 source: Some(source),
373 }
374 }
375}
376
377impl std::fmt::Display for SegmentError {
378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379 f.write_str(&self.message)?;
380 if let Some(src) = &self.source {
381 write!(f, ": {src}")?;
382 }
383 Ok(())
384 }
385}
386
387impl std::error::Error for SegmentError {
388 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
389 self.source.as_deref().map(|e| e as &dyn std::error::Error)
390 }
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404#[non_exhaustive]
405pub struct RenderContext {
406 pub terminal_width: u16,
409}
410
411impl RenderContext {
412 #[must_use]
413 pub fn new(terminal_width: u16) -> Self {
414 Self { terminal_width }
415 }
416}
417
418pub trait Segment: Send {
419 fn render(&self, ctx: &DataContext, rc: &RenderContext) -> RenderResult;
430
431 #[allow(unused_variables)]
451 #[must_use]
452 fn shrink_to_fit(
453 &self,
454 ctx: &DataContext,
455 rc: &RenderContext,
456 target: u16,
457 ) -> Option<RenderedSegment> {
458 None
459 }
460
461 #[must_use]
475 fn data_deps(&self) -> &'static [DataDep] {
476 &[DataDep::Status]
477 }
478
479 #[must_use]
486 fn defaults(&self) -> SegmentDefaults {
487 SegmentDefaults::default()
488 }
489}
490
491pub const DEFAULT_SEGMENT_IDS: &[&str] = &[
499 "model",
500 "context_window",
501 "cost",
502 "effort",
503 "git_branch",
504 "workspace",
505];
506
507pub const BUILT_IN_SEGMENT_IDS: &[&str] = &[
517 "model",
518 "context_window",
519 "context_bar",
520 "workspace",
521 "cost",
522 "effort",
523 "output_style",
524 "vim",
525 "agent",
526 "git_branch",
527 "rate_limit_5h",
528 "rate_limit_7d",
529 "rate_limit_5h_reset",
530 "rate_limit_7d_reset",
531 "extra_usage",
532 "session_duration",
533 "tokens_input",
534 "tokens_output",
535 "tokens_cached",
536 "tokens_total",
537 "version",
538];
539
540#[must_use]
554pub fn built_in_by_id(
555 id: &str,
556 extras: Option<&std::collections::BTreeMap<String, toml::Value>>,
557 warn: &mut impl FnMut(&str),
558) -> Option<Box<dyn Segment>> {
559 let empty: std::collections::BTreeMap<String, toml::Value> = std::collections::BTreeMap::new();
560 let e = extras.unwrap_or(&empty);
561 match id {
562 "model" => Some(Box::new(model::ModelSegment::from_extras(e, warn))),
563 "context_window" => Some(Box::new(context_window::ContextWindowSegment)),
564 "context_bar" => Some(Box::new(context_bar::ContextBarSegment::from_extras(
565 e, warn,
566 ))),
567 "workspace" => Some(Box::new(workspace::WorkspaceSegment)),
568 "cost" => Some(Box::new(cost::CostSegment)),
569 "effort" => Some(Box::new(effort::EffortSegment)),
570 "output_style" => Some(Box::new(output_style::OutputStyleSegment)),
571 "vim" => Some(Box::new(vim::VimSegment)),
572 "agent" => Some(Box::new(agent::AgentSegment)),
573 "git_branch" => Some(Box::new(git_branch::GitBranchSegment::from_extras(e, warn))),
574 "rate_limit_5h" => Some(Box::new(
575 rate_limit::five_hour::RateLimit5hSegment::from_extras(e, warn),
576 )),
577 "rate_limit_7d" => Some(Box::new(
578 rate_limit::seven_day::RateLimit7dSegment::from_extras(e, warn),
579 )),
580 "rate_limit_5h_reset" => Some(Box::new(
581 rate_limit::five_hour::RateLimit5hResetSegment::from_extras(e, warn),
582 )),
583 "rate_limit_7d_reset" => Some(Box::new(
584 rate_limit::seven_day::RateLimit7dResetSegment::from_extras(e, warn),
585 )),
586 "extra_usage" => Some(Box::new(extra_usage::ExtraUsageSegment::from_extras(
587 e, warn,
588 ))),
589 "session_duration" => Some(Box::new(session_duration::SessionDurationSegment)),
590 "tokens_input" => Some(Box::new(tokens::TokensInputSegment)),
591 "tokens_output" => Some(Box::new(tokens::TokensOutputSegment)),
592 "tokens_cached" => Some(Box::new(tokens::TokensCachedSegment)),
593 "tokens_total" => Some(Box::new(tokens::TokensTotalSegment)),
594 "version" => Some(Box::new(version::VersionSegment::from_extras(e, warn))),
595 _ => None,
596 }
597}
598
599pub struct OverriddenSegment {
603 inner: Box<dyn Segment>,
604 priority: Option<u8>,
605 width: Option<WidthBounds>,
606 user_style: Option<Style>,
607}
608
609impl OverriddenSegment {
610 #[must_use]
611 pub fn new(inner: Box<dyn Segment>) -> Self {
612 Self {
613 inner,
614 priority: None,
615 width: None,
616 user_style: None,
617 }
618 }
619
620 #[must_use]
621 pub fn with_priority(mut self, priority: u8) -> Self {
622 self.priority = Some(priority);
623 self
624 }
625
626 #[must_use]
627 pub fn with_width(mut self, bounds: WidthBounds) -> Self {
628 self.width = Some(bounds);
629 self
630 }
631
632 #[must_use]
635 pub fn with_user_style(mut self, style: Style) -> Self {
636 self.user_style = Some(style);
637 self
638 }
639}
640
641impl Segment for OverriddenSegment {
642 fn render(&self, ctx: &DataContext, rc: &RenderContext) -> RenderResult {
643 let result = self.inner.render(ctx, rc)?;
644 Ok(result.map(|r| match &self.user_style {
645 Some(override_style) => {
646 let merged = merge_user_override(r.style(), override_style);
647 r.with_style(merged)
648 }
649 None => r,
650 }))
651 }
652
653 fn shrink_to_fit(
654 &self,
655 ctx: &DataContext,
656 rc: &RenderContext,
657 target: u16,
658 ) -> Option<RenderedSegment> {
659 let inner = self.inner.shrink_to_fit(ctx, rc, target)?;
660 Some(match &self.user_style {
661 Some(override_style) => {
662 let merged = merge_user_override(inner.style(), override_style);
663 inner.with_style(merged)
664 }
665 None => inner,
666 })
667 }
668
669 fn data_deps(&self) -> &'static [DataDep] {
670 self.inner.data_deps()
671 }
672
673 fn defaults(&self) -> SegmentDefaults {
674 let mut d = self.inner.defaults();
675 if let Some(p) = self.priority {
676 d.priority = p;
677 }
678 if let Some(w) = self.width {
679 d.width = Some(w);
680 }
681 d
682 }
683}
684
685fn merge_user_override(inner: &Style, override_style: &Style) -> Style {
695 let mut merged = override_style.clone();
696 if merged.hyperlink.is_none() {
697 merged.hyperlink = inner.hyperlink.clone();
698 }
699 merged
700}
701
702#[non_exhaustive]
722pub enum LineItem {
723 Segment {
736 id: std::borrow::Cow<'static, str>,
737 segment: Box<dyn Segment>,
738 },
739 Separator(Separator),
740}
741
742impl std::fmt::Debug for LineItem {
743 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
744 match self {
745 Self::Segment { id, segment } => f
749 .debug_struct("Segment")
750 .field("id", id)
751 .field("defaults", &segment.defaults())
752 .finish(),
753 Self::Separator(sep) => f.debug_tuple("Separator").field(sep).finish(),
754 }
755 }
756}
757
758#[cfg(test)]
759mod layout_type_tests {
760 use super::*;
761
762 #[test]
763 fn rendered_segment_computes_width() {
764 let r = RenderedSegment::new("hello");
765 assert_eq!(r.text(), "hello");
766 assert_eq!(r.width(), 5);
767 assert_eq!(r.right_separator(), None);
768 }
769
770 #[test]
771 fn rendered_segment_counts_cells_not_bytes_for_middle_dot() {
772 let r = RenderedSegment::new("42% · 200k");
774 assert_eq!(r.width(), 10);
775 }
776
777 #[test]
778 fn rendered_segment_strips_csi_clear_screen_injection() {
779 let r = RenderedSegment::new("evil\x1b[2J");
781 assert_eq!(r.text(), "evil[2J");
782 assert_eq!(r.width(), 7);
783 assert!(!r.text().contains('\x1b'));
784 }
785
786 #[test]
787 fn rendered_segment_strips_osc_set_title_with_bel_terminator() {
788 let r = RenderedSegment::new("\x1b]0;pwn\x07rest");
791 assert_eq!(r.text(), "]0;pwnrest");
792 assert!(!r.text().contains('\x1b'));
793 assert!(!r.text().contains('\x07'));
794 }
795
796 #[test]
797 fn rendered_segment_strips_common_c0_controls() {
798 let r = RenderedSegment::new("a\x07b\x08c\td\ne\rf");
799 assert_eq!(r.text(), "abcdef");
800 assert_eq!(r.width(), 6);
801 }
802
803 #[test]
804 fn rendered_segment_strips_c1_controls_and_del() {
805 let r = RenderedSegment::new("x\u{007F}y\u{0085}z\u{009B}");
806 assert_eq!(r.text(), "xyz");
807 assert_eq!(r.width(), 3);
808 }
809
810 #[test]
811 fn rendered_segment_preserves_unicode_without_controls() {
812 let r = RenderedSegment::new("café · 日本語");
813 assert_eq!(r.text(), "café · 日本語");
814 }
815
816 #[test]
817 fn rendered_segment_empty_string_stays_empty() {
818 let r = RenderedSegment::new("");
819 assert_eq!(r.text(), "");
820 assert_eq!(r.width(), 0);
821 }
822
823 #[test]
824 fn rendered_segment_all_control_input_collapses_to_empty() {
825 let r = RenderedSegment::new("\x1b\x07\n\t");
828 assert_eq!(r.text(), "");
829 assert_eq!(r.width(), 0);
830 }
831
832 #[test]
833 fn rendered_segment_with_separator_also_strips_controls() {
834 let r = RenderedSegment::with_separator("hi\x1bthere", Separator::None);
835 assert_eq!(r.text(), "hithere");
836 assert_eq!(r.width(), 7);
837 }
838
839 #[test]
840 fn rendered_segment_with_separator_exposes_override() {
841 let r = RenderedSegment::with_separator("x", Separator::None);
842 assert_eq!(r.right_separator(), Some(&Separator::None));
843 }
844
845 #[test]
846 fn separator_widths_match_expected() {
847 assert_eq!(Separator::Space.width(), 1);
848 assert_eq!(Separator::Theme.width(), 1);
849 assert_eq!(Separator::None.width(), 0);
850 assert_eq!(Separator::Literal(Cow::Borrowed(" | ")).width(), 3);
851 assert_eq!(Separator::powerline().width(), 3);
856 assert_eq!(
857 Separator::Powerline {
858 width: PowerlineWidth::Two,
859 }
860 .width(),
861 4
862 );
863 }
864
865 #[test]
866 fn width_bounds_rejects_inverted_range() {
867 assert!(WidthBounds::new(20, 10).is_none());
868 assert!(WidthBounds::new(10, 10).is_some());
869 assert!(WidthBounds::new(0, u16::MAX).is_some());
870 }
871
872 #[test]
873 fn line_item_debug_renders_each_variant() {
874 struct StubSeg;
880 impl Segment for StubSeg {
881 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
882 Ok(None)
883 }
884 }
885 let seg = LineItem::Segment {
886 id: std::borrow::Cow::Borrowed("stub"),
887 segment: Box::new(StubSeg),
888 };
889 let sep = LineItem::Separator(Separator::powerline());
890 let seg_dbg = format!("{seg:?}");
891 let sep_dbg = format!("{sep:?}");
892 assert!(seg_dbg.starts_with("Segment {"), "got {seg_dbg:?}");
893 assert!(sep_dbg.starts_with("Separator("), "got {sep_dbg:?}");
894 assert!(seg_dbg.contains("id:"), "got {seg_dbg:?}");
899 assert!(seg_dbg.contains("defaults:"), "got {seg_dbg:?}");
900 assert!(seg_dbg.contains("stub"), "got {seg_dbg:?}");
901 assert!(seg_dbg.contains("priority"), "got {seg_dbg:?}");
902 }
903
904 #[test]
905 fn segment_defaults_default_priority_is_128() {
906 assert_eq!(SegmentDefaults::default().priority, 128);
907 }
908
909 #[test]
910 fn with_priority_preserves_other_defaults() {
911 let d = SegmentDefaults::with_priority(64);
912 assert_eq!(d.priority, 64);
913 assert_eq!(d.width, None);
914 assert!(!d.truncatable);
915 }
916
917 #[test]
918 fn builders_chain_on_segment_defaults() {
919 let bounds = WidthBounds::new(4, 40).expect("valid bounds");
920 let d = SegmentDefaults::with_priority(32)
921 .with_width(bounds)
922 .with_truncatable(true);
923 assert_eq!(d.priority, 32);
924 assert_eq!(d.width, Some(bounds));
925 assert!(d.truncatable);
926 }
927
928 #[test]
929 fn segment_error_display_includes_message_only_without_source() {
930 let err = SegmentError::new("missing rate_limits field");
931 assert_eq!(err.to_string(), "missing rate_limits field");
932 }
933
934 #[test]
935 fn segment_error_display_chains_source() {
936 let src = std::io::Error::new(std::io::ErrorKind::NotFound, "cache.json");
937 let err = SegmentError::with_source("cache read failed", Box::new(src));
938 let rendered = err.to_string();
939 assert!(rendered.starts_with("cache read failed: "));
940 assert!(rendered.contains("cache.json"));
941 }
942
943 #[test]
944 fn segment_error_source_chain_is_walkable() {
945 use std::error::Error;
946 let src = std::io::Error::other("inner");
947 let err = SegmentError::with_source("outer", Box::new(src));
948 let source = err.source().expect("source present");
949 assert_eq!(source.to_string(), "inner");
950 }
951
952 #[test]
955 fn built_in_by_id_resolves_every_default_segment() {
956 for id in DEFAULT_SEGMENT_IDS {
957 assert!(
958 built_in_by_id(id, None, &mut |_| {}).is_some(),
959 "expected built-in registry to know {id}"
960 );
961 }
962 }
963
964 #[test]
965 fn built_in_by_id_resolves_additional_documented_ids() {
966 for id in [
967 "context_bar",
968 "session_duration",
969 "rate_limit_5h",
970 "rate_limit_7d",
971 "rate_limit_5h_reset",
972 "rate_limit_7d_reset",
973 "extra_usage",
974 "tokens_input",
975 "tokens_output",
976 "tokens_cached",
977 "tokens_total",
978 "output_style",
979 "vim",
980 "agent",
981 ] {
982 assert!(
983 built_in_by_id(id, None, &mut |_| {}).is_some(),
984 "expected {id} to resolve"
985 );
986 }
987 }
988
989 #[test]
990 fn built_in_by_id_resolves_every_id_in_built_in_segment_ids() {
991 for id in BUILT_IN_SEGMENT_IDS {
995 assert!(
996 built_in_by_id(id, None, &mut |_| {}).is_some(),
997 "BUILT_IN_SEGMENT_IDS lists {id} but built_in_by_id can't construct it"
998 );
999 }
1000 }
1001
1002 #[test]
1003 fn built_in_by_id_rejects_unknown() {
1004 assert!(built_in_by_id("nope", None, &mut |_| {}).is_none());
1005 assert!(built_in_by_id("", None, &mut |_| {}).is_none());
1006 }
1007
1008 #[test]
1009 fn built_in_by_id_threads_extras_to_version_segment() {
1010 use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
1015 use std::collections::BTreeMap;
1016 use std::path::PathBuf;
1017 use std::sync::Arc;
1018
1019 let mut extras = BTreeMap::new();
1020 extras.insert("prefix".to_string(), toml::Value::String("CC ".to_string()));
1021 let seg = built_in_by_id("version", Some(&extras), &mut |_| {})
1022 .expect("version segment resolves");
1023
1024 let ctx = DataContext::new(StatusContext {
1025 tool: Tool::ClaudeCode,
1026 model: Some(ModelInfo {
1027 display_name: "X".into(),
1028 }),
1029 workspace: Some(WorkspaceInfo {
1030 project_dir: PathBuf::from("/r"),
1031 git_worktree: None,
1032 }),
1033 context_window: None,
1034 cost: None,
1035 effort: None,
1036 vim: None,
1037 output_style: None,
1038 agent_name: None,
1039 version: Some("2.1.90".into()),
1040 raw: Arc::new(serde_json::Value::Null),
1041 });
1042 let rc = RenderContext::new(80);
1043 let rendered = seg.render(&ctx, &rc).unwrap().expect("renders");
1044 assert_eq!(rendered.text(), "CC 2.1.90");
1045 }
1046
1047 #[test]
1050 fn overridden_segment_replaces_priority() {
1051 let base = built_in_by_id("workspace", None, &mut |_| {}).expect("known id");
1052 let base_priority = base.defaults().priority;
1053 let wrapped = OverriddenSegment::new(base).with_priority(200);
1054 assert_eq!(wrapped.defaults().priority, 200);
1055 assert_ne!(wrapped.defaults().priority, base_priority);
1056 }
1057
1058 #[test]
1059 fn overridden_segment_replaces_width_bounds() {
1060 let base = built_in_by_id("workspace", None, &mut |_| {}).expect("known id");
1061 assert_eq!(base.defaults().width, None);
1062 let bounds = WidthBounds::new(5, 40).expect("valid");
1063 let wrapped = OverriddenSegment::new(base).with_width(bounds);
1064 assert_eq!(wrapped.defaults().width, Some(bounds));
1065 }
1066
1067 #[test]
1068 fn overridden_segment_delegates_render_to_inner() {
1069 let wrapped =
1070 OverriddenSegment::new(built_in_by_id("workspace", None, &mut |_| {}).unwrap())
1071 .with_priority(0);
1072 let rendered = wrapped
1073 .render(&stub_ctx(), &stub_rc())
1074 .unwrap()
1075 .expect("rendered");
1076 assert_eq!(rendered.text(), "linesmith");
1077 }
1078
1079 #[test]
1080 fn style_override_wholesale_replaces_inner_declared_style() {
1081 struct Styled;
1084 impl Segment for Styled {
1085 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1086 Ok(Some(
1087 RenderedSegment::new("x")
1088 .with_role(Role::Accent)
1089 .with_style(Style {
1090 bold: true,
1091 ..Style::default()
1092 }),
1093 ))
1094 }
1095 fn defaults(&self) -> SegmentDefaults {
1096 SegmentDefaults::with_priority(0)
1097 }
1098 }
1099 let override_style = Style {
1100 role: Some(Role::Primary),
1101 italic: true,
1102 ..Style::default()
1103 };
1104 let wrapped =
1105 OverriddenSegment::new(Box::new(Styled)).with_user_style(override_style.clone());
1106 let rendered = wrapped
1107 .render(&stub_ctx(), &stub_rc())
1108 .unwrap()
1109 .expect("rendered");
1110 assert_eq!(rendered.style, override_style);
1111 }
1112
1113 #[test]
1114 fn user_style_override_preserves_inner_hyperlink() {
1115 struct Linked;
1122 impl Segment for Linked {
1123 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1124 Ok(Some(RenderedSegment::new("x").with_style(
1125 Style::default().with_hyperlink("https://example.com"),
1126 )))
1127 }
1128 fn defaults(&self) -> SegmentDefaults {
1129 SegmentDefaults::with_priority(0)
1130 }
1131 }
1132 let override_style = Style::role(Role::Error);
1133 let wrapped =
1134 OverriddenSegment::new(Box::new(Linked)).with_user_style(override_style.clone());
1135 let rendered = wrapped
1136 .render(&stub_ctx(), &stub_rc())
1137 .unwrap()
1138 .expect("rendered");
1139 assert_eq!(rendered.style.role, Some(Role::Error));
1140 assert_eq!(
1141 rendered.style.hyperlink.as_deref(),
1142 Some("https://example.com"),
1143 );
1144 }
1145
1146 #[test]
1147 fn style_override_preserves_inner_none_return() {
1148 struct Hidden;
1149 impl Segment for Hidden {
1150 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1151 Ok(None)
1152 }
1153 fn defaults(&self) -> SegmentDefaults {
1154 SegmentDefaults::with_priority(0)
1155 }
1156 }
1157 let wrapped =
1158 OverriddenSegment::new(Box::new(Hidden)).with_user_style(Style::role(Role::Primary));
1159 assert_eq!(wrapped.render(&stub_ctx(), &stub_rc()).unwrap(), None);
1160 }
1161
1162 #[test]
1163 fn shrink_to_fit_passthrough_reaches_inner_with_user_style_applied() {
1164 struct Shrinkable;
1171 impl Segment for Shrinkable {
1172 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1173 Ok(Some(RenderedSegment::new("full")))
1174 }
1175 fn shrink_to_fit(
1176 &self,
1177 _: &DataContext,
1178 _: &RenderContext,
1179 target: u16,
1180 ) -> Option<RenderedSegment> {
1181 let r = RenderedSegment::new("c");
1182 (r.width <= target).then_some(r)
1183 }
1184 }
1185 let override_style = Style {
1186 role: Some(Role::Primary),
1187 italic: true,
1188 ..Style::default()
1189 };
1190 let wrapped =
1191 OverriddenSegment::new(Box::new(Shrinkable)).with_user_style(override_style.clone());
1192 let shrunk = wrapped
1193 .shrink_to_fit(&stub_ctx(), &stub_rc(), 5)
1194 .expect("inner returned compact form");
1195 assert_eq!(shrunk.text, "c");
1196 assert_eq!(shrunk.style, override_style);
1197 }
1198
1199 #[test]
1200 fn shrink_to_fit_passthrough_keeps_inner_style_when_no_user_override() {
1201 struct ShrinkableWithRole;
1208 impl Segment for ShrinkableWithRole {
1209 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1210 Ok(Some(RenderedSegment::new("full").with_role(Role::Accent)))
1211 }
1212 fn shrink_to_fit(
1213 &self,
1214 _: &DataContext,
1215 _: &RenderContext,
1216 _target: u16,
1217 ) -> Option<RenderedSegment> {
1218 Some(RenderedSegment::new("c").with_role(Role::Accent))
1219 }
1220 }
1221 let wrapped = OverriddenSegment::new(Box::new(ShrinkableWithRole));
1223 let shrunk = wrapped
1224 .shrink_to_fit(&stub_ctx(), &stub_rc(), 10)
1225 .expect("inner returned compact form");
1226 assert_eq!(shrunk.style.role, Some(Role::Accent));
1227 }
1228
1229 #[test]
1230 fn shrink_to_fit_passthrough_returns_none_when_inner_declines() {
1231 struct Plain;
1234 impl Segment for Plain {
1235 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1236 Ok(Some(RenderedSegment::new("plain")))
1237 }
1238 }
1239 let wrapped =
1240 OverriddenSegment::new(Box::new(Plain)).with_user_style(Style::role(Role::Primary));
1241 assert!(wrapped
1242 .shrink_to_fit(&stub_ctx(), &stub_rc(), 100)
1243 .is_none());
1244 }
1245
1246 fn stub_ctx() -> DataContext {
1247 use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
1248 use std::path::PathBuf;
1249 use std::sync::Arc;
1250 DataContext::new(StatusContext {
1251 tool: Tool::ClaudeCode,
1252 model: Some(ModelInfo {
1253 display_name: "Claude".into(),
1254 }),
1255 workspace: Some(WorkspaceInfo {
1256 project_dir: PathBuf::from("/repo/linesmith"),
1257 git_worktree: None,
1258 }),
1259 context_window: None,
1260 cost: None,
1261 effort: None,
1262 vim: None,
1263 output_style: None,
1264 agent_name: None,
1265 version: None,
1266 raw: Arc::new(serde_json::Value::Null),
1267 })
1268 }
1269
1270 fn stub_rc() -> RenderContext {
1271 RenderContext::new(80)
1272 }
1273}