Skip to content

Commit 9edb10d

Browse files
committed
feat(hud): smart context bar visualization (Wave 2-E)
Replaces the plain "Ctx:42%" text with a compact visual progress bar so users can see context-window usage at a glance: [████░░░░░░] 42% (safe, low usage) [███████░░░] 73% (approaching danger) [█████████▓] 92%⚠ (critical — full bar + warning glyph) Two visual signals distinguish the danger zones: 1. Dark glyph (▓) replaces the trailing full block at the danger threshold (85%) so "almost full" looks different from "full". 2. ⚠ suffix appended at the warning threshold (80%) — survives monochrome / greyscale renders. New lib/hud_context_bar.py: - CONTEXT_BAR_WIDTH = 10 cells (~10% resolution per cell) - CONTEXT_BAR_THRESHOLDS = (80, 85, 95) # warning, danger, critical - _clamp_percentage(pct): defensive float coercion with [0, 100] clamp - render_context_bar(used_pct, *, width): core renderer - format_context_bar_segment(stdin_data): Claude Code stdin extractor 29 new tests cover: - Basic shape (0/50/100 pct) - Rounding (banker's round-half-to-even for 5/15/95) - Warning/danger threshold boundaries (inclusive) - Clamping (negative, >100, None, non-numeric, numeric string) - Custom width (20, 0, 1) - Segment extractor (empty, missing context_window, missing used_percentage, normal render, zero) - Constants (width, threshold ordering) 158/158 pass. Part of #1464 (Wave 0 statusbar refactor)
1 parent de622cc commit 9edb10d

2 files changed

Lines changed: 334 additions & 15 deletions

File tree

Lines changed: 132 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,136 @@
1-
"""Smart context bar visualization for CodingBuddy statusLine (#1326).
1+
"""Smart context bar visualization for CodingBuddy statusLine (#1326, Wave 2-E).
22
3-
Wave 0 skeleton — reserved for **Wave 2-E**.
3+
Renders the context-window usage percentage as a compact visual
4+
progress bar so users can see at a glance how close they are to
5+
overflow:
46
5-
Planned contents (Wave 2-E owner fills):
6-
* ``CONTEXT_BAR_WIDTH: int`` — segment count
7-
* ``CONTEXT_BAR_THRESHOLDS: tuple[float, float, float]`` — warning
8-
/ danger / critical cut-offs
9-
* ``render_context_bar(used_tokens: int, total_tokens: int) -> str``
7+
``[████░░░░░░] 42%`` (safe — low usage)
8+
``[███████░░░] 73%`` (warning — approaching the danger zone)
9+
``[█████████▓] 92%⚠`` (critical — near exhaustion)
1010
11-
Wave 2-E will render the bar from the ``context`` payload already
12-
parsed in ``codingbuddy-hud``. This file is a reserved import target
13-
so Wave 3 integration can depend on ``hud_context_bar`` without
14-
creating the module mid-merge.
11+
Primary entry point: :func:`render_context_bar`.
12+
13+
The bar width is :data:`CONTEXT_BAR_WIDTH` (10 cells by default),
14+
chosen to keep the status line compact while still providing
15+
meaningful resolution (each cell represents ~10% of the budget).
16+
17+
Thresholds (:data:`CONTEXT_BAR_THRESHOLDS`) drive two visual
18+
signals:
19+
20+
1. A dark-shade glyph (``▓``) replaces the trailing full block when
21+
usage crosses the *danger* threshold — so the last cell pulses
22+
visually even when the bar looks otherwise full.
23+
2. A ``⚠`` suffix is appended when usage crosses the *warning*
24+
threshold — a distinct text marker that survives greyscale /
25+
monochrome renders.
1526
"""
27+
from __future__ import annotations
28+
29+
from typing import Any, Dict, Tuple
30+
31+
# ------------------------------------------------------------------------
32+
# Constants
33+
# ------------------------------------------------------------------------
34+
35+
#: Number of cells in the rendered bar. 10 gives ~10% resolution per cell.
36+
CONTEXT_BAR_WIDTH: int = 10
37+
38+
#: (warning, danger, critical) thresholds as percentages.
39+
#:
40+
#: * ``warning`` (80) — append a ``⚠`` suffix
41+
#: * ``danger`` (85) — replace the trailing full block with ``▓``
42+
#: * ``critical`` (95) — both signals always active
43+
CONTEXT_BAR_THRESHOLDS: Tuple[float, float, float] = (80.0, 85.0, 95.0)
44+
45+
# Block-drawing glyphs
46+
_FULL = "\u2588" # █
47+
_EMPTY = "\u2591" # ░
48+
_DARK = "\u2593" # ▓
49+
50+
# Warning suffix
51+
_WARN = "\u26a0" # ⚠
52+
53+
54+
def _clamp_percentage(pct: Any) -> float:
55+
"""Coerce an arbitrary value to a percentage in ``[0.0, 100.0]``.
56+
57+
Non-numeric inputs return ``0.0``. Values above 100 are capped
58+
at 100; values below 0 are floored at 0.
59+
"""
60+
try:
61+
value = float(pct)
62+
except (TypeError, ValueError):
63+
return 0.0
64+
if value < 0.0:
65+
return 0.0
66+
if value > 100.0:
67+
return 100.0
68+
return value
69+
70+
71+
def render_context_bar(
72+
used_pct: Any,
73+
*,
74+
width: int = CONTEXT_BAR_WIDTH,
75+
) -> str:
76+
"""Render a context-bar string from a usage percentage.
77+
78+
Output format:
79+
80+
``[<bar>] <N>%[⚠]``
81+
82+
The bar contains ``width`` cells; each cell represents
83+
``100 / width`` percent. The number of filled cells is
84+
``round(used_pct / 100 * width)``. When usage crosses the
85+
danger threshold, the last full block becomes ``▓`` to make
86+
the "full" state visually distinct from a true max. When usage
87+
crosses the warning threshold, a trailing ``⚠`` is appended.
88+
89+
Args:
90+
used_pct: Context-window usage percentage (0-100).
91+
Accepts ``int``/``float``/numeric string. Non-numeric
92+
input renders as ``0%``.
93+
width: Override the bar cell count (tests / layout tuning).
94+
95+
Returns an empty string when ``width <= 0``.
96+
"""
97+
if width <= 0:
98+
return ""
99+
100+
pct = _clamp_percentage(used_pct)
101+
warning, danger, _critical = CONTEXT_BAR_THRESHOLDS
102+
103+
# Number of filled cells (rounded for UX — 5% fills half a cell).
104+
filled = int(round(pct / 100.0 * width))
105+
if filled < 0:
106+
filled = 0
107+
if filled > width:
108+
filled = width
109+
110+
# Build the bar
111+
bar_cells = [_FULL] * filled + [_EMPTY] * (width - filled)
112+
113+
# Danger glyph replaces the trailing full block
114+
if pct >= danger and filled > 0:
115+
bar_cells[filled - 1] = _DARK
116+
117+
bar = "".join(bar_cells)
118+
suffix = _WARN if pct >= warning else ""
119+
return f"[{bar}] {pct:.0f}%{suffix}"
120+
121+
122+
def format_context_bar_segment(stdin_data: Dict[str, Any]) -> str:
123+
"""Render the context bar from a Claude Code stdin payload.
124+
125+
Extracts ``context_window.used_percentage`` and forwards to
126+
:func:`render_context_bar`. Returns an empty string when the
127+
field is absent — callers can append the result conditionally
128+
without surrounding logic.
129+
"""
130+
if not stdin_data:
131+
return ""
132+
ctx = stdin_data.get("context_window") or {}
133+
pct = ctx.get("used_percentage")
134+
if pct is None:
135+
return ""
136+
return render_context_bar(pct)
Lines changed: 202 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Skeleton sanity for hud_context_bar Wave 2-E placeholder (#1463)."""
1+
"""Behavior tests for hud_context_bar (Wave 2-E / #1326)."""
22
import os
33
import sys
44

@@ -9,7 +9,205 @@
99
if _p not in sys.path:
1010
sys.path.insert(0, _p)
1111

12+
import hud_context_bar # noqa: E402
1213

13-
def test_module_loads():
14-
"""Contract: hud_context_bar must be importable. Wave 2-E will add real assertions."""
15-
import hud_context_bar # noqa: F401
14+
_FULL = "\u2588"
15+
_EMPTY = "\u2591"
16+
_DARK = "\u2593"
17+
_WARN = "\u26a0"
18+
19+
20+
# --------------------------- render_context_bar: basic shape --------------
21+
22+
23+
def test_zero_percent_all_empty():
24+
result = hud_context_bar.render_context_bar(0)
25+
assert _FULL not in result
26+
assert _EMPTY * hud_context_bar.CONTEXT_BAR_WIDTH in result
27+
assert "0%" in result
28+
29+
30+
def test_fifty_percent_half_filled():
31+
result = hud_context_bar.render_context_bar(50)
32+
# 50% of 10 cells = 5 filled
33+
assert _FULL * 5 in result
34+
assert _EMPTY * 5 in result
35+
assert "50%" in result
36+
37+
38+
def test_hundred_percent_all_filled():
39+
"""100% shows the full bar but also danger glyph + warning suffix."""
40+
result = hud_context_bar.render_context_bar(100)
41+
# Danger glyph replaces last cell
42+
assert _DARK in result
43+
# And warning suffix
44+
assert _WARN in result
45+
assert "100%" in result
46+
47+
48+
# --------------------------- shape: format --------------------------------
49+
50+
51+
def test_output_has_bracket_wrapper():
52+
result = hud_context_bar.render_context_bar(42)
53+
assert result.startswith("[")
54+
assert "] " in result
55+
56+
57+
def test_output_shows_percent_symbol():
58+
result = hud_context_bar.render_context_bar(42)
59+
assert "42%" in result
60+
61+
62+
# --------------------------- rounding -------------------------------------
63+
64+
65+
def test_rounding_nearest_5pct_fills_half_cell():
66+
"""5% rounds up to 1 filled cell (round-half-to-even)."""
67+
result = hud_context_bar.render_context_bar(5)
68+
# filled = round(5/100 * 10) = round(0.5) = 0 (banker's rounding)
69+
# So 0 full cells expected.
70+
assert _FULL not in result
71+
72+
73+
def test_rounding_15pct_fills_2_cells():
74+
"""15% → 1.5 → rounds to 2 (banker's rounding to even)."""
75+
result = hud_context_bar.render_context_bar(15)
76+
assert _FULL * 2 in result
77+
78+
79+
def test_rounding_95pct_fills_10_with_danger():
80+
result = hud_context_bar.render_context_bar(95)
81+
# filled = round(9.5) = 10 (banker's) → full bar with danger glyph
82+
assert result.count(_FULL) + result.count(_DARK) == 10
83+
assert _DARK in result
84+
assert _WARN in result
85+
86+
87+
# --------------------------- warning / danger thresholds ------------------
88+
89+
90+
def test_below_warning_no_suffix():
91+
result = hud_context_bar.render_context_bar(50)
92+
assert _WARN not in result
93+
94+
95+
def test_at_warning_threshold_adds_suffix():
96+
"""80% is the warning threshold (inclusive)."""
97+
result = hud_context_bar.render_context_bar(80)
98+
assert _WARN in result
99+
100+
101+
def test_above_warning_has_suffix():
102+
result = hud_context_bar.render_context_bar(85)
103+
assert _WARN in result
104+
105+
106+
def test_below_danger_no_dark_glyph():
107+
result = hud_context_bar.render_context_bar(80)
108+
# 80 is warning but below danger (85) → no dark glyph
109+
assert _DARK not in result
110+
111+
112+
def test_at_danger_threshold_has_dark_glyph():
113+
"""85% is the danger threshold (inclusive)."""
114+
result = hud_context_bar.render_context_bar(85)
115+
assert _DARK in result
116+
117+
118+
def test_above_danger_has_dark_glyph():
119+
result = hud_context_bar.render_context_bar(92)
120+
assert _DARK in result
121+
122+
123+
# --------------------------- clamping -------------------------------------
124+
125+
126+
def test_negative_clamped_to_zero():
127+
result = hud_context_bar.render_context_bar(-50)
128+
assert "0%" in result
129+
assert _FULL not in result
130+
131+
132+
def test_above_100_clamped_to_100():
133+
result = hud_context_bar.render_context_bar(150)
134+
assert "100%" in result
135+
136+
137+
def test_non_numeric_treated_as_zero():
138+
result = hud_context_bar.render_context_bar("abc")
139+
assert "0%" in result
140+
141+
142+
def test_none_treated_as_zero():
143+
result = hud_context_bar.render_context_bar(None)
144+
assert "0%" in result
145+
146+
147+
def test_numeric_string_accepted():
148+
result = hud_context_bar.render_context_bar("42")
149+
assert "42%" in result
150+
151+
152+
# --------------------------- custom width ---------------------------------
153+
154+
155+
def test_custom_width_20():
156+
result = hud_context_bar.render_context_bar(50, width=20)
157+
# 50% of 20 = 10 filled
158+
assert _FULL * 10 in result
159+
160+
161+
def test_width_zero_returns_empty():
162+
assert hud_context_bar.render_context_bar(50, width=0) == ""
163+
164+
165+
def test_width_one_minimal_bar():
166+
"""Width 1 is a degenerate but valid case."""
167+
result = hud_context_bar.render_context_bar(100, width=1)
168+
assert "[" in result
169+
assert "]" in result
170+
assert "100%" in result
171+
172+
173+
# --------------------------- format_context_bar_segment -------------------
174+
175+
176+
def test_segment_empty_stdin():
177+
assert hud_context_bar.format_context_bar_segment({}) == ""
178+
179+
180+
def test_segment_no_context_window():
181+
assert hud_context_bar.format_context_bar_segment({"cost": {}}) == ""
182+
183+
184+
def test_segment_missing_used_percentage():
185+
stdin = {"context_window": {"total_tokens": 1000}}
186+
assert hud_context_bar.format_context_bar_segment(stdin) == ""
187+
188+
189+
def test_segment_normal_render():
190+
stdin = {"context_window": {"used_percentage": 42}}
191+
result = hud_context_bar.format_context_bar_segment(stdin)
192+
assert "42%" in result
193+
assert "[" in result
194+
195+
196+
def test_segment_zero_renders():
197+
"""Zero percent still renders (not same as missing)."""
198+
stdin = {"context_window": {"used_percentage": 0}}
199+
result = hud_context_bar.format_context_bar_segment(stdin)
200+
assert "0%" in result
201+
202+
203+
# --------------------------- constants ------------------------------------
204+
205+
206+
def test_context_bar_width_default():
207+
assert hud_context_bar.CONTEXT_BAR_WIDTH == 10
208+
209+
210+
def test_thresholds_ordered():
211+
"""warning ≤ danger ≤ critical."""
212+
w, d, c = hud_context_bar.CONTEXT_BAR_THRESHOLDS
213+
assert w <= d <= c

0 commit comments

Comments
 (0)