1use std::collections::BTreeMap;
9
10use unicode_width::UnicodeWidthStr;
11
12use super::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
13use crate::data_context::DataContext;
14use crate::theme::Role;
15
16const PRIORITY: u8 = 112;
21
22const ID: &str = "context_bar";
23
24const DEFAULT_WIDTH: u16 = 10;
25const DEFAULT_GREEN_THRESHOLD: u8 = 50;
26const DEFAULT_YELLOW_THRESHOLD: u8 = 80;
27
28const DEFAULT_FULL: char = '█';
29const DEFAULT_PARTIAL: char = '▓';
30const DEFAULT_EMPTY: char = '░';
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub(crate) struct Config {
34 pub(crate) width: u16,
35 pub(crate) thresholds: Thresholds,
36 pub(crate) chars: BarChars,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub(crate) struct Thresholds {
47 green: u8,
48 yellow: u8,
49}
50
51impl Thresholds {
52 pub(crate) fn new(green: u8, yellow: u8) -> Option<Self> {
54 if green <= yellow && yellow <= 100 {
55 Some(Self { green, yellow })
56 } else {
57 None
58 }
59 }
60
61 pub(crate) fn green(self) -> u8 {
62 self.green
63 }
64
65 pub(crate) fn yellow(self) -> u8 {
66 self.yellow
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub(crate) struct BarChars {
72 pub(crate) full: String,
73 pub(crate) partial: String,
74 pub(crate) empty: String,
75}
76
77impl Default for Config {
78 fn default() -> Self {
79 Self {
80 width: DEFAULT_WIDTH,
81 thresholds: Thresholds::new(DEFAULT_GREEN_THRESHOLD, DEFAULT_YELLOW_THRESHOLD)
82 .expect("DEFAULT_GREEN_THRESHOLD <= DEFAULT_YELLOW_THRESHOLD by construction"),
83 chars: BarChars {
84 full: DEFAULT_FULL.to_string(),
85 partial: DEFAULT_PARTIAL.to_string(),
86 empty: DEFAULT_EMPTY.to_string(),
87 },
88 }
89 }
90}
91
92#[derive(Default)]
93pub struct ContextBarSegment {
94 pub(crate) cfg: Config,
95}
96
97impl ContextBarSegment {
98 pub fn from_extras(
106 extras: &BTreeMap<String, toml::Value>,
107 warn: &mut impl FnMut(&str),
108 ) -> Self {
109 let mut cfg = Config::default();
110
111 if let Some(v) = extras.get("cells") {
112 match v.as_integer().and_then(|n| u16::try_from(n).ok()) {
113 Some(n) if n >= 1 => cfg.width = n,
114 _ => warn(&format!(
115 "segments.{ID}.cells: expected 1..=65535; ignoring"
116 )),
117 }
118 }
119
120 if let Some(t) = extras.get("thresholds").and_then(|v| v.as_table()) {
121 let parse_field = |field: &str, warn: &mut dyn FnMut(&str)| -> Option<u8> {
128 let v = t.get(field)?;
129 match v.as_integer().and_then(|n| u8::try_from(n).ok()) {
130 Some(n) if n <= 100 => Some(n),
131 _ => {
132 warn(&format!(
133 "segments.{ID}.thresholds.{field}: expected 0..=100; ignoring"
134 ));
135 None
136 }
137 }
138 };
139 let green = parse_field("green", &mut |m| warn(m));
140 let yellow = parse_field("yellow", &mut |m| warn(m));
141 let candidate = (
142 green.unwrap_or(cfg.thresholds.green()),
143 yellow.unwrap_or(cfg.thresholds.yellow()),
144 );
145 match Thresholds::new(candidate.0, candidate.1) {
146 Some(t) => cfg.thresholds = t,
147 None => warn(&format!(
148 "segments.{ID}.thresholds: green ({}) must be <= yellow ({}); ignoring both",
149 candidate.0, candidate.1
150 )),
151 }
152 }
153
154 if let Some(c) = extras.get("characters").and_then(|v| v.as_table()) {
155 for (field, slot) in [
156 ("full", &mut cfg.chars.full),
157 ("partial", &mut cfg.chars.partial),
158 ("empty", &mut cfg.chars.empty),
159 ] {
160 let Some(v) = c.get(field) else { continue };
161 match v.as_str() {
168 Some(s) if UnicodeWidthStr::width(s) == 1 => *slot = s.to_string(),
169 Some(s) => warn(&format!(
170 "segments.{ID}.characters.{field}: expected a single-cell string, got {} cell(s); ignoring",
171 UnicodeWidthStr::width(s)
172 )),
173 None => warn(&format!(
174 "segments.{ID}.characters.{field}: expected string; ignoring"
175 )),
176 }
177 }
178 }
179
180 Self { cfg }
181 }
182}
183
184impl Segment for ContextBarSegment {
185 fn render(&self, ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
186 let Some(cw) = ctx.status.context_window.as_ref() else {
187 crate::lsm_debug!("context_bar: status.context_window absent; hiding");
188 return Ok(None);
189 };
190 let Some(used) = cw.used else {
194 crate::lsm_debug!("context_bar: used null; hiding");
195 return Ok(None);
196 };
197 let pct = used.value().round_ties_even();
204 let bar = render_bar(pct, &self.cfg);
205 let role = role_for_pct(pct, self.cfg.thresholds);
206 Ok(Some(RenderedSegment::new(bar).with_role(role)))
207 }
208
209 fn defaults(&self) -> SegmentDefaults {
210 SegmentDefaults::with_priority(PRIORITY)
211 }
212}
213
214fn role_for_pct(pct: f32, t: Thresholds) -> Role {
215 if pct < f32::from(t.green()) {
216 Role::Success
217 } else if pct < f32::from(t.yellow()) {
218 Role::Warning
219 } else {
220 Role::Error
221 }
222}
223
224fn render_bar(pct: f32, cfg: &Config) -> String {
225 let width = usize::from(cfg.width);
226 let cells_filled = (f32::from(cfg.width) * pct / 100.0).clamp(0.0, f32::from(cfg.width));
227 let full = cells_filled.floor() as usize;
228 let frac = cells_filled - cells_filled.floor();
229 let partial = if full < width && frac >= 0.5 { 1 } else { 0 };
230 let empty = width.saturating_sub(full).saturating_sub(partial);
231
232 let mut out = String::with_capacity(
233 full * cfg.chars.full.len()
234 + partial * cfg.chars.partial.len()
235 + empty * cfg.chars.empty.len(),
236 );
237 for _ in 0..full {
238 out.push_str(&cfg.chars.full);
239 }
240 for _ in 0..partial {
241 out.push_str(&cfg.chars.partial);
242 }
243 for _ in 0..empty {
244 out.push_str(&cfg.chars.empty);
245 }
246 out
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::input::{ContextWindow, ModelInfo, Percent, StatusContext, Tool, WorkspaceInfo};
253 use std::path::PathBuf;
254 use std::sync::Arc;
255
256 fn rc() -> RenderContext {
257 RenderContext::new(80)
258 }
259
260 fn ctx(window: Option<ContextWindow>) -> DataContext {
261 DataContext::new(StatusContext {
262 tool: Tool::ClaudeCode,
263 model: Some(ModelInfo {
264 display_name: "X".into(),
265 }),
266 workspace: Some(WorkspaceInfo {
267 project_dir: PathBuf::from("/repo"),
268 git_worktree: None,
269 }),
270 context_window: window,
271 cost: None,
272 effort: None,
273 vim: None,
274 output_style: None,
275 agent_name: None,
276 version: None,
277 raw: Arc::new(serde_json::Value::Null),
278 })
279 }
280
281 fn window(used: f32, size: u32) -> ContextWindow {
282 ContextWindow {
283 used: Some(Percent::new(used).expect("in range")),
284 size: Some(size),
285 total_input_tokens: Some(0),
286 total_output_tokens: Some(0),
287 current_usage: None,
288 }
289 }
290
291 #[test]
292 fn renders_zero_percent_as_all_empty() {
293 let r = ContextBarSegment::default()
294 .render(&ctx(Some(window(0.0, 200_000))), &rc())
295 .unwrap()
296 .expect("rendered");
297 assert_eq!(r.text(), "░░░░░░░░░░");
298 assert_eq!(r.style().role, Some(Role::Success));
299 }
300
301 #[test]
302 fn renders_full_at_one_hundred() {
303 let r = ContextBarSegment::default()
304 .render(&ctx(Some(window(100.0, 200_000))), &rc())
305 .unwrap()
306 .expect("rendered");
307 assert_eq!(r.text(), "██████████");
308 assert_eq!(r.style().role, Some(Role::Error));
309 }
310
311 #[test]
312 fn renders_partial_block_when_fraction_geq_half() {
313 let r = ContextBarSegment::default()
315 .render(&ctx(Some(window(45.0, 200_000))), &rc())
316 .unwrap()
317 .expect("rendered");
318 assert_eq!(r.text(), "████▓░░░░░");
319 }
320
321 #[test]
322 fn rounds_down_when_fraction_lt_half() {
323 let r = ContextBarSegment::default()
325 .render(&ctx(Some(window(42.0, 200_000))), &rc())
326 .unwrap()
327 .expect("rendered");
328 assert_eq!(r.text(), "████░░░░░░");
329 }
330
331 #[test]
332 fn renders_fifty_percent_at_threshold_boundary_yellow() {
333 let r = ContextBarSegment::default()
335 .render(&ctx(Some(window(50.0, 200_000))), &rc())
336 .unwrap()
337 .expect("rendered");
338 assert_eq!(r.text(), "█████░░░░░");
339 assert_eq!(r.style().role, Some(Role::Warning));
340 }
341
342 #[test]
343 fn red_threshold_at_eighty_percent() {
344 let r = ContextBarSegment::default()
346 .render(&ctx(Some(window(80.0, 200_000))), &rc())
347 .unwrap()
348 .expect("rendered");
349 assert_eq!(r.style().role, Some(Role::Error));
350 }
351
352 #[test]
353 fn green_at_one_below_threshold() {
354 let r = ContextBarSegment::default()
355 .render(&ctx(Some(window(49.0, 200_000))), &rc())
356 .unwrap()
357 .expect("rendered");
358 assert_eq!(r.style().role, Some(Role::Success));
359 }
360
361 #[test]
362 fn yellow_at_one_below_red_threshold() {
363 let r = ContextBarSegment::default()
364 .render(&ctx(Some(window(79.0, 200_000))), &rc())
365 .unwrap()
366 .expect("rendered");
367 assert_eq!(r.style().role, Some(Role::Warning));
368 }
369
370 #[test]
371 fn hidden_when_context_window_absent() {
372 assert_eq!(
373 ContextBarSegment::default()
374 .render(&ctx(None), &rc())
375 .unwrap(),
376 None
377 );
378 }
379
380 #[test]
381 fn defaults_use_expected_priority() {
382 assert_eq!(ContextBarSegment::default().defaults().priority, PRIORITY);
383 }
384
385 #[test]
386 fn rendered_width_matches_configured_cells_for_default_chars() {
387 let r = ContextBarSegment::default()
389 .render(&ctx(Some(window(45.0, 200_000))), &rc())
390 .unwrap()
391 .expect("rendered");
392 assert_eq!(r.width(), 10);
393 }
394
395 #[test]
396 fn from_extras_sets_width() {
397 let extras = BTreeMap::from([("cells".to_string(), toml::Value::Integer(5))]);
398 let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
399 assert_eq!(seg.cfg.width, 5);
400 }
401
402 #[test]
403 fn from_extras_warns_on_zero_width() {
404 let extras = BTreeMap::from([("cells".to_string(), toml::Value::Integer(0))]);
405 let mut warnings = vec![];
406 let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
407 assert_eq!(seg.cfg.width, DEFAULT_WIDTH);
408 assert!(warnings.iter().any(|w| w.contains("cells")));
409 }
410
411 #[test]
412 fn from_extras_warns_on_negative_width() {
413 let extras = BTreeMap::from([("cells".to_string(), toml::Value::Integer(-1))]);
414 let mut warnings = vec![];
415 let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
416 assert_eq!(seg.cfg.width, DEFAULT_WIDTH);
417 assert!(warnings.iter().any(|w| w.contains("cells")));
418 }
419
420 #[test]
421 fn from_extras_reads_thresholds_table() {
422 let mut t = toml::value::Table::new();
423 t.insert("green".to_string(), toml::Value::Integer(30));
424 t.insert("yellow".to_string(), toml::Value::Integer(70));
425 let extras = BTreeMap::from([("thresholds".to_string(), toml::Value::Table(t))]);
426 let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
427 assert_eq!(seg.cfg.thresholds.green(), 30);
428 assert_eq!(seg.cfg.thresholds.yellow(), 70);
429 }
430
431 #[test]
432 fn from_extras_accepts_high_pair_above_defaults() {
433 let mut t = toml::value::Table::new();
437 t.insert("green".to_string(), toml::Value::Integer(90));
438 t.insert("yellow".to_string(), toml::Value::Integer(95));
439 let extras = BTreeMap::from([("thresholds".to_string(), toml::Value::Table(t))]);
440 let mut warnings = vec![];
441 let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
442 assert_eq!(seg.cfg.thresholds.green(), 90);
443 assert_eq!(seg.cfg.thresholds.yellow(), 95);
444 assert!(
445 warnings.is_empty(),
446 "no warnings expected; got {warnings:?}"
447 );
448 }
449
450 #[test]
451 fn from_extras_accepts_low_pair_below_defaults() {
452 let mut t = toml::value::Table::new();
456 t.insert("green".to_string(), toml::Value::Integer(10));
457 t.insert("yellow".to_string(), toml::Value::Integer(20));
458 let extras = BTreeMap::from([("thresholds".to_string(), toml::Value::Table(t))]);
459 let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
460 assert_eq!(seg.cfg.thresholds.green(), 10);
461 assert_eq!(seg.cfg.thresholds.yellow(), 20);
462 }
463
464 #[test]
465 fn from_extras_rejects_inverted_pair_and_keeps_defaults() {
466 let mut t = toml::value::Table::new();
469 t.insert("green".to_string(), toml::Value::Integer(80));
470 t.insert("yellow".to_string(), toml::Value::Integer(50));
471 let extras = BTreeMap::from([("thresholds".to_string(), toml::Value::Table(t))]);
472 let mut warnings = vec![];
473 let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
474 assert_eq!(seg.cfg.thresholds.green(), DEFAULT_GREEN_THRESHOLD);
475 assert_eq!(seg.cfg.thresholds.yellow(), DEFAULT_YELLOW_THRESHOLD);
476 assert!(warnings
477 .iter()
478 .any(|w| w.contains("must be <=") && w.contains("ignoring both")));
479 }
480
481 #[test]
482 fn from_extras_rejects_lone_green_against_default_yellow() {
483 let mut t = toml::value::Table::new();
486 t.insert("green".to_string(), toml::Value::Integer(90));
487 let extras = BTreeMap::from([("thresholds".to_string(), toml::Value::Table(t))]);
488 let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
489 assert_eq!(seg.cfg.thresholds.green(), DEFAULT_GREEN_THRESHOLD);
490 assert_eq!(seg.cfg.thresholds.yellow(), DEFAULT_YELLOW_THRESHOLD);
491 }
492
493 #[test]
494 fn from_extras_warns_when_threshold_out_of_range() {
495 let mut t = toml::value::Table::new();
499 t.insert("green".to_string(), toml::Value::Integer(150));
500 let extras = BTreeMap::from([("thresholds".to_string(), toml::Value::Table(t))]);
501 let mut warnings = vec![];
502 let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
503 assert_eq!(seg.cfg.thresholds.green(), DEFAULT_GREEN_THRESHOLD);
504 assert_eq!(seg.cfg.thresholds.yellow(), DEFAULT_YELLOW_THRESHOLD);
505 assert!(warnings.iter().any(|w| w.contains("green")));
506 }
507
508 #[test]
509 fn thresholds_new_rejects_inverted() {
510 assert!(Thresholds::new(80, 50).is_none());
511 assert!(Thresholds::new(50, 80).is_some());
512 assert!(Thresholds::new(50, 50).is_some());
513 assert!(Thresholds::new(0, 100).is_some());
514 assert!(Thresholds::new(0, 101).is_none());
515 }
516
517 #[test]
518 fn from_extras_reads_characters_table() {
519 let mut c = toml::value::Table::new();
520 c.insert("full".to_string(), toml::Value::String("#".to_string()));
521 c.insert("partial".to_string(), toml::Value::String("=".to_string()));
522 c.insert("empty".to_string(), toml::Value::String("-".to_string()));
523 let extras = BTreeMap::from([("characters".to_string(), toml::Value::Table(c))]);
524 let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
525 let r = seg
526 .render(&ctx(Some(window(45.0, 200_000))), &rc())
527 .unwrap()
528 .expect("rendered");
529 assert_eq!(r.text(), "####=-----");
530 }
531
532 #[test]
533 fn custom_width_changes_bar_length() {
534 let extras = BTreeMap::from([("cells".to_string(), toml::Value::Integer(5))]);
535 let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
536 let r = seg
537 .render(&ctx(Some(window(40.0, 200_000))), &rc())
538 .unwrap()
539 .expect("rendered");
540 assert_eq!(r.text(), "██░░░");
542 }
543
544 #[test]
545 fn pct_is_rounded_before_threshold_so_text_and_bar_agree() {
546 let r = ContextBarSegment::default()
550 .render(&ctx(Some(window(49.9, 200_000))), &rc())
551 .unwrap()
552 .expect("rendered");
553 assert_eq!(r.style().role, Some(Role::Warning));
554 }
555
556 #[test]
557 fn pct_is_rounded_so_high_fractional_paints_red_with_full_bar() {
558 let r = ContextBarSegment::default()
560 .render(&ctx(Some(window(99.9, 200_000))), &rc())
561 .unwrap()
562 .expect("rendered");
563 assert_eq!(r.text(), "██████████");
564 assert_eq!(r.style().role, Some(Role::Error));
565 }
566
567 #[test]
568 fn frac_above_half_renders_partial_distinct_from_round() {
569 let r = ContextBarSegment::default()
574 .render(&ctx(Some(window(47.0, 200_000))), &rc())
575 .unwrap()
576 .expect("rendered");
577 assert_eq!(r.text(), "████▓░░░░░");
578 }
579
580 #[test]
581 fn pct_round_ties_to_even_matches_format_rounding() {
582 let r = ContextBarSegment::default()
586 .render(&ctx(Some(window(50.5, 200_000))), &rc())
587 .unwrap()
588 .expect("rendered");
589 assert_eq!(r.text(), "█████░░░░░");
592 assert_eq!(r.style().role, Some(Role::Warning));
593 }
594
595 #[test]
596 fn cells_one_below_half_renders_empty_with_color_role() {
597 let extras = BTreeMap::from([("cells".to_string(), toml::Value::Integer(1))]);
603 let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
604 let r = seg
605 .render(&ctx(Some(window(30.0, 200_000))), &rc())
606 .unwrap()
607 .expect("rendered");
608 assert_eq!(r.text(), "░");
609 assert_eq!(r.style().role, Some(Role::Success));
610 }
611
612 #[test]
613 fn frac_just_below_half_drops_partial_block() {
614 let r = ContextBarSegment::default()
617 .render(&ctx(Some(window(44.0, 200_000))), &rc())
618 .unwrap()
619 .expect("rendered");
620 assert_eq!(r.text(), "████░░░░░░");
621 }
622
623 #[test]
624 fn from_extras_warns_on_non_string_character() {
625 let mut c = toml::value::Table::new();
626 c.insert("full".to_string(), toml::Value::Integer(42));
627 let extras = BTreeMap::from([("characters".to_string(), toml::Value::Table(c))]);
628 let mut warnings = vec![];
629 let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
630 assert_eq!(seg.cfg.chars.full, DEFAULT_FULL.to_string());
631 assert!(warnings
632 .iter()
633 .any(|w| w.contains("full") && w.contains("string")));
634 }
635
636 #[test]
637 fn from_extras_rejects_multi_cell_glyph() {
638 let mut c = toml::value::Table::new();
640 c.insert("full".to_string(), toml::Value::String("漢".to_string()));
641 let extras = BTreeMap::from([("characters".to_string(), toml::Value::Table(c))]);
642 let mut warnings = vec![];
643 let seg = ContextBarSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
644 assert_eq!(seg.cfg.chars.full, DEFAULT_FULL.to_string());
645 assert!(warnings.iter().any(|w| w.contains("single-cell")));
646 }
647
648 #[test]
649 fn from_extras_partial_characters_override_leaves_others_default() {
650 let mut c = toml::value::Table::new();
651 c.insert("full".to_string(), toml::Value::String("#".to_string()));
652 let extras = BTreeMap::from([("characters".to_string(), toml::Value::Table(c))]);
653 let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
654 assert_eq!(seg.cfg.chars.full, "#");
655 assert_eq!(seg.cfg.chars.partial, DEFAULT_PARTIAL.to_string());
656 assert_eq!(seg.cfg.chars.empty, DEFAULT_EMPTY.to_string());
657 }
658
659 #[test]
660 fn priority_drops_before_context_window() {
661 let bar_pri = ContextBarSegment::default().defaults().priority;
664 let window_pri = super::super::context_window::ContextWindowSegment
665 .defaults()
666 .priority;
667 assert!(bar_pri > window_pri);
668 }
669
670 #[test]
671 fn one_cell_width_renders_single_char() {
672 let extras = BTreeMap::from([("cells".to_string(), toml::Value::Integer(1))]);
673 let seg = ContextBarSegment::from_extras(&extras, &mut |_| {});
674 let empty = seg
675 .render(&ctx(Some(window(0.0, 200_000))), &rc())
676 .unwrap()
677 .expect("rendered");
678 assert_eq!(empty.text(), "░");
679 let full = seg
680 .render(&ctx(Some(window(100.0, 200_000))), &rc())
681 .unwrap()
682 .expect("rendered");
683 assert_eq!(full.text(), "█");
684 }
685}