diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ed2ea..08a7d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] + +### Added +- Added `filled_text` to `cship.context_bar`, which fills the bar with successive characters of a string instead of repeating `filled_char`, so the bar can spell out a word or phrase as the context window fills (`CONTEXT░░░░░░░ 50%`). The bar is as wide as the text; zero-width characters such as combining accents join the character before them, and the track keeps the text's display width, so with a single-column `empty_char` wide characters don't make the bar grow as it fills. A `filled_text` with no visible characters is ignored with a warn-once diagnostic. +- Added `full_at` to `cship.context_bar`, the percentage at which the bar is drawn full (default `100`). Claude Code auto-compacts before the context window reaches 100%, so a bar scaled to 100% never completes; set `full_at` to your auto-compact point to fill it in time. Thresholds still compare the real percentage, and a `full_at` at or below 0 is ignored with a warn-once diagnostic. +- Added `filled_style` and `track_style` to `cship.context_bar`, which style the filled slots and the track on their own instead of sharing one style with the percentage. The threshold style then covers only the percentage, and each unset part style falls back to it. With neither set, the bar renders exactly as before. + ## [1.8.3] - 2026-09-06 ### Fixed diff --git a/docs/configuration.md b/docs/configuration.md index df21042..40050e9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -201,6 +201,10 @@ Renders a visual ASCII progress bar showing context window usage. | `width` | `integer` | `10` | Number of characters in the bar | | `filled_char` | `string` | `"█"` | Character used for the filled portion. Any Unicode character is allowed. | | `empty_char` | `string` | `"░"` | Character used for the empty portion. Any Unicode character is allowed. | +| `filled_text` | `string` | — | Text revealed one character per slot as the bar fills, in place of `filled_char`. The bar is as wide as the text, so `width` is ignored. Zero-width characters such as combining accents join the character before them, and the track keeps the text's display width (with a single-column `empty_char`). Text with no visible characters is ignored. Emoji made of several characters (skin tones, flags, joined emoji) aren't supported. | +| `full_at` | `float` | `100` | % at which the bar is drawn full. Set it to your auto-compact point (e.g. `95`) to finish the bar before Claude Code auto-compacts. Thresholds still compare the real percentage. | +| `filled_style` | `string` | — | Style for the filled portion, in place of the threshold style | +| `track_style` | `string` | — | Style for the empty portion (the track), in place of the threshold style | | `warn_threshold` | `float` | — | % at which style switches to `warn_style` | | `warn_style` | `string` | `"yellow"` | Style at warn level | | `critical_threshold` | `float` | — | % at which style switches to `critical_style` | @@ -225,6 +229,23 @@ filled_char = "●" empty_char = "○" ``` +To reveal text as the context window fills (`CONTEXT░░░░░░░ 50%`), complete by 95%: + +```toml +[cship.context_bar] +filled_text = "CONTEXT WINDOW" +full_at = 95 +``` + +`filled_style` and `track_style` style the two parts of the bar on their own; the threshold style then covers only the percentage. Set them instead of styling `$value` in `format`: `$style` is left empty (`$symbol` takes the threshold style itself), and a style written directly in `format` doesn't reach the bar. For a bar whose text sits on a dark track: + +```toml +[cship.context_bar] +filled_text = "CONTEXT WINDOW" +filled_style = "bold fg:#FFFFFF bg:#4C4C4C" +track_style = "fg:#929292 bg:#000000" +``` + --- ## `[cship.context_window]` — Context Window Details diff --git a/docs/faq.md b/docs/faq.md index b9c0c81..0ba9d1b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -210,3 +210,19 @@ You can see the current cache state by running `cship explain` — it shows the cship strips terminal control characters (ESC, BEL, the C0/C1 ranges, and DEL — including tab, carriage return, and newline) from untrusted session JSON fields such as `cwd`, `transcript_path`, `model`, and `workspace` before rendering. This is a security measure: it prevents a malicious directory or model name from injecting raw escape sequences that could spoof your terminal title, move the cursor, or write to the clipboard via OSC 52 ([CWE-150](https://cwe.mitre.org/data/definitions/150.html)). Normal values are never affected — only control bytes are removed. If a path or name shows up with characters missing, it contained control bytes, which are stripped by design and cannot be disabled. + +--- + +## How do I make the context bar spell out a word? + +Set `filled_text` in `[cship.context_bar]`: the bar fills with successive characters of that text instead of repeating `filled_char`, and `full_at` sets the percentage at which it's complete — for example just before Claude Code auto-compacts. `full_at` only rescales the bar; `warn_threshold` and `critical_threshold` still compare the real percentage. + +```toml +[cship.context_bar] +filled_text = "CONTEXT WINDOW" # CONTEXT░░░░░░░ at 50% +full_at = 95 +filled_style = "bold" +track_style = "fg:#929292" +``` + +`filled_style` and `track_style` style the text and the track on their own, and the threshold style (`style`, `warn_style`, `critical_style`) then covers only the percentage. Style the bar through these fields rather than in a `format` string: with either set, `$style` is left empty (`$symbol` takes the threshold style itself), and a style written directly in `format` (e.g. `[$value](fg:#FFFFFF)`) doesn't reach the bar. diff --git a/docs/public/config-schema.json b/docs/public/config-schema.json index 3d7d5aa..94c56fd 100644 --- a/docs/public/config-schema.json +++ b/docs/public/config-schema.json @@ -547,6 +547,35 @@ "string", "null" ] + }, + "filled_text": { + "description": "Text revealed one character per slot as the bar fills, in place of `filled_char`.\nThe bar is as wide as the text, so `width` is ignored. Zero-width characters (such\nas combining accents) join the character before them, and the track keeps the\ntext's display width, so with a single-column `empty_char` the bar doesn't change\nwidth as it fills. Text with no visible characters is ignored. Emoji made of\nseveral characters (skin tones, flags, joined emoji like 👨‍👩‍👦) aren't supported:\nthey can reveal in pieces and shift the bar's width.\nExample: `\"CONTEXT WINDOW\"` renders `CONTEXT░░░░░░░ 50%` at 50%.", + "type": [ + "string", + "null" + ] + }, + "full_at": { + "description": "Percentage at which the bar is drawn full. Defaults to `100`; set it lower to\nfinish the bar before Claude Code auto-compacts. It only rescales the bar:\nthresholds still compare the real percentage. Values at or below 0 are ignored.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "filled_style": { + "description": "Style for the filled slots, in place of the threshold style. Setting this or\n`track_style` styles each part on its own, so the threshold style then covers\nonly the percentage. Style the bar through these fields rather than in `format`:\n`$style` is left empty (`$symbol` takes the threshold style itself), and a style\nwritten directly in `format` doesn't reach the bar.\nNot used when context data is absent; `empty_style` covers that state.", + "type": [ + "string", + "null" + ] + }, + "track_style": { + "description": "Style for the empty slots (the track), in place of the threshold style.\nSee `filled_style`.", + "type": [ + "string", + "null" + ] } } }, diff --git a/src/ansi.rs b/src/ansi.rs index 219dd7e..946f7db 100644 --- a/src/ansi.rs +++ b/src/ansi.rs @@ -44,6 +44,12 @@ pub fn apply_style(content: &str, style_str: Option<&str>) -> String { style.paint(content).to_string() } +/// Prefix `content` with an SGR reset, so a style opened before it (e.g. by a literal span +/// in a `format` string) doesn't carry into content that styles its own parts. +pub fn after_reset(content: &str) -> String { + format!("\x1b[0m{content}") +} + /// Apply style with optional numeric threshold switching. /// If `value` >= `critical_threshold` (both Some), applies `critical_style`. /// If `value` >= `warn_threshold` (both Some), applies `warn_style`. diff --git a/src/config.rs b/src/config.rs index e6dfe2d..d194442 100644 --- a/src/config.rs +++ b/src/config.rs @@ -204,6 +204,29 @@ pub struct ContextBarConfig { /// Character used for empty (unused) slots. Defaults to `"░"`. /// Example: `"○"` for hollow circles. pub empty_char: Option, + /// Text revealed one character per slot as the bar fills, in place of `filled_char`. + /// The bar is as wide as the text, so `width` is ignored. Zero-width characters (such + /// as combining accents) join the character before them, and the track keeps the + /// text's display width, so with a single-column `empty_char` the bar doesn't change + /// width as it fills. Text with no visible characters is ignored. Emoji made of + /// several characters (skin tones, flags, joined emoji like 👨‍👩‍👦) aren't supported: + /// they can reveal in pieces and shift the bar's width. + /// Example: `"CONTEXT WINDOW"` renders `CONTEXT░░░░░░░ 50%` at 50%. + pub filled_text: Option, + /// Percentage at which the bar is drawn full. Defaults to `100`; set it lower to + /// finish the bar before Claude Code auto-compacts. It only rescales the bar: + /// thresholds still compare the real percentage. Values at or below 0 are ignored. + pub full_at: Option, + /// Style for the filled slots, in place of the threshold style. Setting this or + /// `track_style` styles each part on its own, so the threshold style then covers + /// only the percentage. Style the bar through these fields rather than in `format`: + /// `$style` is left empty (`$symbol` takes the threshold style itself), and a style + /// written directly in `format` doesn't reach the bar. + /// Not used when context data is absent; `empty_style` covers that state. + pub filled_style: Option, + /// Style for the empty slots (the track), in place of the threshold style. + /// See `filled_style`. + pub track_style: Option, } /// Configuration for `[cship.context_window]` sub-field modules. diff --git a/src/modules/context_bar.rs b/src/modules/context_bar.rs index 1c32cdb..1863e06 100644 --- a/src/modules/context_bar.rs +++ b/src/modules/context_bar.rs @@ -9,6 +9,8 @@ const DEFAULT_EMPTY_CHAR: &str = "░"; /// Format: `{bar} {used_percentage:.0}%` e.g. `███░░░░░░░ 35%` /// Bar width is configurable via `[cship.context_bar].width` (default 10). /// Fill characters are configurable via `filled_char` (default `"█"`) and `empty_char` (default `"░"`). +/// `filled_text` reveals text one character per slot instead of repeating `filled_char`. +/// `filled_style` / `track_style` style the filled slots and the track on their own. /// /// Source: epics.md#Story 2.2, prd.md#FR8 pub fn render(ctx: &Context, cfg: &CshipConfig) -> Option { @@ -31,12 +33,43 @@ pub fn render(ctx: &Context, cfg: &CshipConfig) -> Option { None => (0.0f64, true), }; - let width = bar_cfg.and_then(|c| c.width).unwrap_or(DEFAULT_BAR_WIDTH) as usize; + let slots = match bar_cfg.and_then(|c| c.filled_text.as_deref()) { + Some(text) if crate::ansi::display_width(text) == 0 => { + // Text with no visible characters would render no bar at all — ignore it, warn once + static EMPTY_TEXT_WARNED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + if !EMPTY_TEXT_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) { + tracing::warn!( + "cship.context_bar: filled_text has no visible characters — ignoring it" + ); + } + None + } + Some(text) => Some(text_slots(text)), + None => None, + }; + let width = match &slots { + Some(slots) => slots.len(), + None => bar_cfg.and_then(|c| c.width).unwrap_or(DEFAULT_BAR_WIDTH) as usize, + }; + let full_at = match bar_cfg.and_then(|c| c.full_at) { + Some(pct) if pct > 0.0 => pct, + Some(_) => { + // A full_at at or below 0 would never fill the bar sensibly — ignore it, warn once + static FULL_AT_WARNED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + if !FULL_AT_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) { + tracing::warn!("cship.context_bar: full_at must be above 0 — ignoring it"); + } + 100.0 + } + None => 100.0, + }; // Floor via `as usize` truncation — NOT round. The percentage text uses {:.0} (round). // At boundary values like 99.5%, bar shows 9/10 filled while text shows "100%". // This is intentional: the bar is a visual approximation, the number is canonical. - let filled = ((used_pct / 100.0) * width as f64) as usize; - let filled = filled.min(width); // guard floating-point edge at 100% + let filled = ((used_pct / full_at) * width as f64) as usize; + let filled = filled.min(width); // cap once used_pct reaches full_at let empty = width - filled; let filled_char = bar_cfg @@ -45,8 +78,18 @@ pub fn render(ctx: &Context, cfg: &CshipConfig) -> Option { let empty_char = bar_cfg .and_then(|c| c.empty_char.as_deref()) .unwrap_or(DEFAULT_EMPTY_CHAR); - let bar: String = filled_char.repeat(filled) + &empty_char.repeat(empty); - let bar_content = format!("{bar} {:.0}%", used_pct); + // The track takes the hidden text's display width, so with a single-column + // empty_char, wide characters don't make the bar grow as it fills. + let (filled_part, track_width) = match &slots { + Some(slots) => ( + slots[..filled].concat(), + crate::ansi::display_width(&slots[filled..].concat()), + ), + None => (filled_char.repeat(filled), empty), + }; + let empty_part = empty_char.repeat(track_width); + let percentage = format!("{:.0}%", used_pct); + let bar_content = format!("{filled_part}{empty_part} {percentage}"); let symbol = bar_cfg.and_then(|c| c.symbol.as_deref()); let style = bar_cfg.and_then(|c| c.style.as_deref()); @@ -71,6 +114,46 @@ pub fn render(ctx: &Context, cfg: &CshipConfig) -> Option { let critical_threshold = bar_cfg.and_then(|c| c.critical_threshold); let critical_style = bar_cfg.and_then(|c| c.critical_style.as_deref()); + // Per-part styles paint the filled slots, the track and the percentage on their own, + // with the threshold style on the percentage. Nothing may wrap the painted parts: an + // outer style's attributes (e.g. bold) would leak into any part that doesn't reset them. + let filled_style = bar_cfg.and_then(|c| c.filled_style.as_deref()); + let track_style = bar_cfg.and_then(|c| c.track_style.as_deref()); + if filled_style.is_some() || track_style.is_some() { + let threshold_style = crate::ansi::resolve_threshold_style( + Some(used_pct), + style, + warn_threshold, + warn_style, + critical_threshold, + critical_style, + ); + // An empty part (the text at 0%, the track at 100%) is left out rather than + // painted as a bare pair of escape codes. + let paint = |part: &str, style: Option<&str>| match part { + "" => String::new(), + _ => crate::ansi::apply_style(part, style), + }; + let painted = format!( + "{}{} {}", + paint(&filled_part, filled_style.or(threshold_style)), + paint(&empty_part, track_style.or(threshold_style)), + crate::ansi::apply_style(&percentage, threshold_style), + ); + // `$style` stays empty so it can't wrap the painted parts; `$symbol` takes the + // threshold style itself. + let symbol = symbol.map(|s| paint(s, threshold_style)); + return match bar_cfg.and_then(|c| c.format.as_deref()) { + Some(fmt) => { + // Start the bar from a reset, so a style written directly in `format` + // (e.g. `[$value](bold)`) doesn't carry into the parts. + let painted = crate::ansi::after_reset(&painted); + crate::format::apply_module_format(fmt, Some(&painted), symbol.as_deref(), None) + } + None => Some(painted), + }; + } + // Format string takes priority if configured (AC2) if let Some(fmt) = bar_cfg.and_then(|c| c.format.as_deref()) { let effective_style = crate::ansi::resolve_threshold_style( @@ -102,9 +185,27 @@ pub fn render(ctx: &Context, cfg: &CshipConfig) -> Option { )) } +/// Splits `text` into one slot per visible character: zero-width characters, such as +/// combining accents, join the character before them. Note: emoji made of several +/// characters (skin tones, flags, joined emoji like 👨‍👩‍👦) aren't supported: they split +/// into several slots, so they can reveal in pieces and shift the bar's width. Splitting +/// into grapheme clusters would need a new dependency. +fn text_slots(text: &str) -> Vec { + use unicode_width::UnicodeWidthChar; + let mut slots: Vec = Vec::new(); + for c in text.chars() { + match slots.last_mut() { + Some(last) if c.width() == Some(0) => last.push(c), + _ => slots.push(c.to_string()), + } + } + slots +} + #[cfg(test)] mod tests { use super::*; + use crate::ansi::apply_style; use crate::config::{ContextBarConfig, CshipConfig}; use crate::context::{Context, ContextWindow}; @@ -447,4 +548,265 @@ mod tests { assert_eq!(filled, 5, "expected 5 filled circles: {result:?}"); assert_eq!(empty, 5, "expected 5 default empty chars: {result:?}"); } + + fn render_bar(pct: f64, bar: ContextBarConfig) -> String { + let cfg = CshipConfig { + context_bar: Some(bar), + ..Default::default() + }; + render(&ctx_with_pct(pct), &cfg).unwrap() + } + + #[test] + fn test_context_bar_filled_text_reveals_one_char_per_slot() { + let bar = || ContextBarConfig { + filled_text: Some("CONTEXT WINDOW".to_string()), + width: Some(4), // ignored: the bar is as wide as the text + ..Default::default() + }; + assert_eq!(render_bar(0.0, bar()), "░░░░░░░░░░░░░░ 0%"); + assert_eq!(render_bar(50.0, bar()), "CONTEXT░░░░░░░ 50%"); + assert_eq!(render_bar(100.0, bar()), "CONTEXT WINDOW 100%"); + } + + #[test] + fn test_context_bar_filled_text_keeps_its_width_with_wide_chars() { + let bar = || ContextBarConfig { + filled_text: Some("日本語テキスト".to_string()), + ..Default::default() + }; + // 7 characters, each two columns wide: the track stands in for the hidden ones + assert_eq!(render_bar(0.0, bar()), format!("{} 0%", "░".repeat(14))); + assert_eq!( + render_bar(50.0, bar()), + format!("日本語{} 50%", "░".repeat(8)) + ); + assert_eq!(render_bar(100.0, bar()), "日本語テキスト 100%"); + } + + #[test] + fn test_context_bar_filled_text_joins_combining_marks() { + let bar = || ContextBarConfig { + filled_text: Some("cafe\u{301} bar".to_string()), + ..Default::default() + }; + // 8 slots: the accent joins its "e" + assert_eq!(render_bar(50.0, bar()), "cafe\u{301}░░░░ 50%"); + assert_eq!(render_bar(100.0, bar()), "cafe\u{301} bar 100%"); + } + + #[test] + fn test_context_bar_invisible_filled_text_keeps_default_bar() { + for text in ["", "\u{301}"] { + let bar = ContextBarConfig { + filled_text: Some(text.to_string()), + ..Default::default() + }; + assert_eq!( + render_bar(50.0, bar), + "█████░░░░░ 50%", + "filled_text {text:?}" + ); + } + } + + #[test] + fn test_context_bar_filled_text_with_format_uses_the_threshold_style() { + let bar = ContextBarConfig { + filled_text: Some("CONTEXT WINDOW".to_string()), + format: Some("[$value]($style)".to_string()), + warn_threshold: Some(50.0), + warn_style: Some("yellow".to_string()), + ..Default::default() + }; + let expected = apply_style("CONTEXT░░░░░░░ 50%", Some("yellow")); + assert_eq!(render_bar(50.0, bar), expected); + } + + #[test] + fn test_context_bar_full_at_fills_the_bar_early() { + let bar = || ContextBarConfig { + full_at: Some(95.0), + ..Default::default() + }; + assert_eq!(render_bar(90.0, bar()), "█████████░ 90%"); // 90/95 of 10 → 9 + assert_eq!(render_bar(95.0, bar()), "██████████ 95%"); + assert_eq!(render_bar(100.0, bar()), "██████████ 100%"); // capped at the width + } + + #[test] + fn test_context_bar_non_positive_full_at_is_ignored() { + let bar = ContextBarConfig { + full_at: Some(0.0), + ..Default::default() + }; + assert_eq!(render_bar(50.0, bar), "█████░░░░░ 50%"); + } + + #[test] + fn test_context_bar_full_at_does_not_move_the_thresholds() { + let bar = || ContextBarConfig { + full_at: Some(95.0), + critical_threshold: Some(98.0), + critical_style: Some("red".to_string()), + ..Default::default() + }; + assert_eq!(render_bar(96.0, bar()), "██████████ 96%"); // full, not yet critical + let expected = apply_style("██████████ 98%", Some("red")); + assert_eq!(render_bar(98.0, bar()), expected); + } + + #[test] + fn test_context_bar_part_styles_paint_each_part_separately() { + let bar = ContextBarConfig { + filled_style: Some("bold".to_string()), + track_style: Some("dimmed".to_string()), + style: Some("red".to_string()), + ..Default::default() + }; + let expected = format!( + "{}{} {}", + apply_style("█████", Some("bold")), + apply_style("░░░░░", Some("dimmed")), + apply_style("50%", Some("red")), + ); + assert_eq!(render_bar(50.0, bar), expected); + } + + #[test] + fn test_context_bar_unset_part_style_falls_back_to_threshold_style() { + let bar = ContextBarConfig { + filled_style: Some("bold".to_string()), + critical_threshold: Some(90.0), + critical_style: Some("red".to_string()), + ..Default::default() + }; + let red = |s: &str| apply_style(s, Some("red")); + let expected = format!( + "{}{} {}", + apply_style("█████████", Some("bold")), + red("░"), + red("95%") + ); + assert_eq!(render_bar(95.0, bar), expected); + } + + #[test] + fn test_context_bar_format_style_does_not_wrap_part_styles() { + let bar = ContextBarConfig { + track_style: Some("dimmed".to_string()), + style: Some("bold".to_string()), + format: Some("[$value]($style)".to_string()), + ..Default::default() + }; + let bold = |s: &str| apply_style(s, Some("bold")); + let parts = format!( + "{}{} {}", + bold("█████"), + apply_style("░░░░░", Some("dimmed")), + bold("50%") + ); + assert_eq!(render_bar(50.0, bar), crate::ansi::after_reset(&parts)); + } + + #[test] + fn test_context_bar_part_styles_give_the_symbol_the_threshold_style() { + let bar = ContextBarConfig { + filled_style: Some("bold".to_string()), + symbol: Some("CTX ".to_string()), + format: Some("[$symbol$value]($style)".to_string()), + critical_threshold: Some(90.0), + critical_style: Some("red".to_string()), + ..Default::default() + }; + let red = |s: &str| apply_style(s, Some("red")); + let bold = apply_style("█████████", Some("bold")); + let parts = format!("{bold}{} {}", red("░"), red("95%")); + let expected = format!("{}{}", red("CTX "), crate::ansi::after_reset(&parts)); + assert_eq!(render_bar(95.0, bar), expected); + } + + #[test] + fn test_context_bar_part_styles_survive_a_style_written_in_format() { + let bar = ContextBarConfig { + filled_style: Some("bold".to_string()), + track_style: Some("dimmed".to_string()), + format: Some("[$value](fg:#FFFFFF)".to_string()), + ..Default::default() + }; + let result = render_bar(50.0, bar); + assert!( + result.contains(&apply_style("█████", Some("bold"))), + "{result:?}" + ); + assert!( + result.contains(&apply_style("░░░░░", Some("dimmed"))), + "{result:?}" + ); + } + + /// The visible characters of `s` a terminal would draw bold. Handles the plain SGR + /// codes these tests use (`1` bold, `0` reset, named colours). + fn bold_chars(s: &str) -> String { + let (mut bold, mut out, mut rest) = (false, String::new(), s); + while let Some(c) = rest.chars().next() { + if let Some(seq) = rest.strip_prefix("\x1b[") { + let end = seq.find('m').unwrap(); + for code in seq[..end].split(';') { + match code { + "0" => bold = false, + "1" => bold = true, + _ => {} + } + } + rest = &seq[end + 1..]; + } else { + if bold { + out.push(c); + } + rest = &rest[c.len_utf8()..]; + } + } + out + } + + #[test] + fn test_context_bar_style_written_in_format_does_not_reach_the_parts() { + let bar = || ContextBarConfig { + filled_text: Some("CONTEXT WINDOW".to_string()), + filled_style: Some("red".to_string()), + format: Some("[$value](bold)".to_string()), + ..Default::default() + }; + for pct in [0.0, 50.0, 100.0] { + assert_eq!(bold_chars(&render_bar(pct, bar())), "", "at {pct}%"); + } + } + + #[test] + fn test_context_bar_part_styles_leave_out_empty_parts() { + let bar = || ContextBarConfig { + filled_style: Some("bold".to_string()), + track_style: Some("dimmed".to_string()), + ..Default::default() + }; + let empty = format!("{} 0%", apply_style("░░░░░░░░░░", Some("dimmed"))); + let full = format!("{} 100%", apply_style("██████████", Some("bold"))); + assert_eq!(render_bar(0.0, bar()), empty); + assert_eq!(render_bar(100.0, bar()), full); + } + + #[test] + fn test_context_bar_part_styles_not_used_without_context_data() { + let cfg = CshipConfig { + context_bar: Some(ContextBarConfig { + track_style: Some("bold".to_string()), + empty_style: Some("dimmed".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let result = render(&Context::default(), &cfg).unwrap(); + assert_eq!(result, apply_style("░░░░░░░░░░ 0%", Some("dimmed"))); + } } diff --git a/tests/cli.rs b/tests/cli.rs index 7676204..aacaf98 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -322,6 +322,28 @@ fn test_context_bar_renders_block_chars_and_percentage() { assert!(stdout.contains("90%"), "expected '90%': {stdout:?}"); } +#[test] +fn test_context_bar_filled_text_reveals_styled_text_from_toml() { + let json = std::fs::read_to_string("tests/fixtures/context_high.json").unwrap(); + // context_high.json: used_percentage = 90.0; with full_at = 95, 13 of 14 characters show + let output = cship() + .args(["--config", "tests/fixtures/context_bar_filled_text.toml"]) + .write_stdin(json) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\x1b[1mCONTEXT WINDO\x1b[0m"), + "expected bold revealed text: {stdout:?}" + ); + assert!( + stdout.contains("\x1b[2m░\x1b[0m"), + "expected dimmed track: {stdout:?}" + ); + assert!(stdout.contains("90%"), "expected '90%': {stdout:?}"); +} + #[test] fn test_context_bar_warn_threshold_applies_ansi_style() { let json = std::fs::read_to_string("tests/fixtures/context_warn.json").unwrap(); diff --git a/tests/fixtures/context_bar_filled_text.toml b/tests/fixtures/context_bar_filled_text.toml new file mode 100644 index 0000000..36f8107 --- /dev/null +++ b/tests/fixtures/context_bar_filled_text.toml @@ -0,0 +1,8 @@ +[cship] +lines = ["$cship.context_bar"] + +[cship.context_bar] +filled_text = "CONTEXT WINDOW" +full_at = 95 +filled_style = "bold" +track_style = "dimmed"