Skip to main content

linesmith_core/segments/
context_window.rs

1//! Context window segment: renders `{used}% · {size}` where size is
2//! formatted in thousands (`200k`, `1M`). Hidden when the payload
3//! doesn't carry context-window data.
4
5use super::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
6use crate::data_context::DataContext;
7use crate::theme::Role;
8
9pub struct ContextWindowSegment;
10
11/// Just above workspace (16): the context-window percentage is the
12/// health metric that silently breaks sessions when it hits 100%, so it
13/// should outlive everything except orientation.
14const PRIORITY: u8 = 32;
15
16impl Segment for ContextWindowSegment {
17    fn render(&self, ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
18        let Some(cw) = ctx.status.context_window.as_ref() else {
19            crate::lsm_debug!("context_window: status.context_window absent; hiding");
20            return Ok(None);
21        };
22        // Per ADR-0014, leaves degrade independently. Render whatever
23        // we have: full `42% · 200k` when both populate, `200k` alone
24        // during the pre-first-API-call window where `used` is null
25        // but `size` is known (orientation outweighs hiding). Hide
26        // when neither is available — there's nothing to say.
27        let text = match (cw.used, cw.size) {
28            (Some(used), Some(size)) => format!("{:.0}% · {}", used.value(), format_size(size)),
29            (None, Some(size)) => format_size(size),
30            (Some(used), None) => format!("{:.0}%", used.value()),
31            (None, None) => {
32                crate::lsm_debug!("context_window: used and size both null; hiding");
33                return Ok(None);
34            }
35        };
36        Ok(Some(RenderedSegment::new(text).with_role(Role::Info)))
37    }
38
39    fn defaults(&self) -> SegmentDefaults {
40        SegmentDefaults::with_priority(PRIORITY)
41    }
42}
43
44fn format_size(size: u32) -> String {
45    if size >= 1_000_000 && size.is_multiple_of(1_000_000) {
46        format!("{}M", size / 1_000_000)
47    } else if size >= 1_000 && size.is_multiple_of(1_000) {
48        format!("{}k", size / 1_000)
49    } else {
50        size.to_string()
51    }
52}
53
54#[cfg(test)]
55fn format_size_for(size: u32) -> String {
56    format_size(size)
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::input::{ContextWindow, ModelInfo, Percent, StatusContext, Tool, WorkspaceInfo};
63    use std::path::PathBuf;
64    use std::sync::Arc;
65
66    fn rc() -> RenderContext {
67        RenderContext::new(80)
68    }
69
70    fn ctx(window: Option<ContextWindow>) -> DataContext {
71        DataContext::new(StatusContext {
72            tool: Tool::ClaudeCode,
73            model: Some(ModelInfo {
74                display_name: "X".into(),
75            }),
76            workspace: Some(WorkspaceInfo {
77                project_dir: PathBuf::from("/repo"),
78                git_worktree: None,
79            }),
80            context_window: window,
81            cost: None,
82            effort: None,
83            vim: None,
84            output_style: None,
85            agent_name: None,
86            version: None,
87            raw: Arc::new(serde_json::Value::Null),
88        })
89    }
90
91    fn window(used: f32, size: u32) -> ContextWindow {
92        ContextWindow {
93            used: Some(Percent::new(used).expect("in range")),
94            size: Some(size),
95            total_input_tokens: Some(0),
96            total_output_tokens: Some(0),
97            current_usage: None,
98        }
99    }
100
101    #[test]
102    fn renders_percent_and_sonnet_200k() {
103        assert_eq!(
104            ContextWindowSegment
105                .render(&ctx(Some(window(42.3, 200_000))), &rc())
106                .unwrap(),
107            Some(RenderedSegment::new("42% · 200k").with_role(Role::Info))
108        );
109    }
110
111    #[test]
112    fn renders_one_million_size_as_m_suffix() {
113        assert_eq!(
114            ContextWindowSegment
115                .render(&ctx(Some(window(5.0, 1_000_000))), &rc())
116                .unwrap(),
117            Some(RenderedSegment::new("5% · 1M").with_role(Role::Info))
118        );
119    }
120
121    #[test]
122    fn renders_non_round_size_literally() {
123        assert_eq!(
124            ContextWindowSegment
125                .render(&ctx(Some(window(10.0, 131_072))), &rc())
126                .unwrap(),
127            Some(RenderedSegment::new("10% · 131072").with_role(Role::Info))
128        );
129    }
130
131    #[test]
132    fn rounds_percent_to_nearest_integer() {
133        assert_eq!(
134            ContextWindowSegment
135                .render(&ctx(Some(window(99.9, 200_000))), &rc())
136                .unwrap(),
137            Some(RenderedSegment::new("100% · 200k").with_role(Role::Info))
138        );
139    }
140
141    #[test]
142    fn hidden_when_context_window_absent() {
143        assert_eq!(
144            ContextWindowSegment.render(&ctx(None), &rc()).unwrap(),
145            None
146        );
147    }
148
149    #[test]
150    fn renders_size_only_when_used_is_null() {
151        // ADR-0014 partial-render goal: pre-first-API-call payloads
152        // have used_percentage null but size populated. Show the
153        // size so orientation survives the ~15s startup window.
154        let cw = ContextWindow {
155            used: None,
156            size: Some(200_000),
157            total_input_tokens: Some(0),
158            total_output_tokens: Some(0),
159            current_usage: None,
160        };
161        assert_eq!(
162            ContextWindowSegment.render(&ctx(Some(cw)), &rc()).unwrap(),
163            Some(RenderedSegment::new("200k").with_role(Role::Info))
164        );
165    }
166
167    #[test]
168    fn renders_percent_only_when_size_is_null() {
169        // Symmetric partial: hypothetical schema-drift case where
170        // used_percentage survives but context_window_size doesn't.
171        let cw = ContextWindow {
172            used: Some(Percent::new(42.0).expect("in range")),
173            size: None,
174            total_input_tokens: Some(0),
175            total_output_tokens: Some(0),
176            current_usage: None,
177        };
178        assert_eq!(
179            ContextWindowSegment.render(&ctx(Some(cw)), &rc()).unwrap(),
180            Some(RenderedSegment::new("42%").with_role(Role::Info))
181        );
182    }
183
184    #[test]
185    fn hidden_when_both_used_and_size_null() {
186        let cw = ContextWindow {
187            used: None,
188            size: None,
189            total_input_tokens: None,
190            total_output_tokens: None,
191            current_usage: None,
192        };
193        assert_eq!(
194            ContextWindowSegment.render(&ctx(Some(cw)), &rc()).unwrap(),
195            None
196        );
197    }
198
199    #[test]
200    fn format_size_round_values_use_k_suffix() {
201        assert_eq!(format_size_for(1_000), "1k");
202        assert_eq!(format_size_for(200_000), "200k");
203    }
204
205    #[test]
206    fn format_size_round_millions_use_m_suffix() {
207        assert_eq!(format_size_for(1_000_000), "1M");
208        assert_eq!(format_size_for(2_000_000), "2M");
209    }
210
211    #[test]
212    fn format_size_non_round_values_rendered_literally() {
213        assert_eq!(format_size_for(999), "999");
214        assert_eq!(format_size_for(131_072), "131072");
215        // 1.5M is not a round million, so the 'k' branch catches it.
216        assert_eq!(format_size_for(1_500_000), "1500k");
217    }
218
219    #[test]
220    fn format_size_zero_renders_literally() {
221        // `0 % 1_000_000 == 0` and `0 >= 1_000_000` is false; same for
222        // the k-branch. Falls through to literal "0" — guard against
223        // a future refactor that flips the order or drops the >= check.
224        assert_eq!(format_size_for(0), "0");
225    }
226
227    #[test]
228    fn defaults_use_expected_priority() {
229        assert_eq!(ContextWindowSegment.defaults().priority, PRIORITY);
230    }
231}