From 9df22fded96c790dbfd0e8496a21c3e780576921 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 12 Apr 2026 00:35:03 +0900 Subject: [PATCH] feat(hud): complete Wave 3 integration for 1-D/2-A/2-D/2-E (Wave 3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit bd78195 ("integrate Wave 2-B/2-C in format_status_line") wired velocity and cache-savings into the cost segment but left four sibling Wave modules as dead code: hud_buddy, hud_rainbow, hud_context_bar, and hud_layout. Their unit tests passed, but format_status_line never called them — the statusLine rendered with a static buddy face, no adaptive layout, text-only context percentage, and no mode coloring. This commit closes that gap by hoisting all four modules as top-level imports (matching the velocity/cache_savings pattern) and refactoring format_status_line to build (name, priority, text) segments consumed by fit_segments. Wave 2-A — breathing buddy face BUDDY_FACE constant → select_face_from_state(hud_state). The face now reflects phase/blockerCount: ready/None → ◕‿◕ (idle) planning/evaluating → ◔‿◔ (thinking) executing/cycling → ◕◡◕ (active) blockerCount > 0 → ◕︵◕ (error, wins over phase) lastEvent=victory → ◕ᴗ◕ (victory, wins over phase) Wave 2-D — mode rainbow ANSI coloring (opt-in) Gated on CODINGBUDDY_HUD_RAINBOW=1 because Claude Code statusLine's ANSI support is environment-dependent; default OFF keeps existing terminals clean. Transitively honours NO_COLOR (https://no-color.org) via hud_rainbow.is_color_enabled. Only real modes (PLAN/ACT/EVAL/AUTO) are colored — the "Ready" fallback label stays plain so tests and logs don't see it uppercased. Coloring is applied POST-fit_segments (string replace on the already-assembled line1) so the ANSI escapes don't break hud_layout.visible_len width accounting during layout. Wave 2-E — smart context bar "Ctx:{pct}%" segment → format_context_bar_segment(stdin_data), rendering e.g. "[████░░░░░░] 42%" or "[████████▓░] 92%⚠" above the warning threshold. Legacy "Ctx:" prefix is gone; the three existing tests asserting "Ctx:45%"/"Ctx:10%" have been updated to check for the new bar + percentage combination. Wave 1-D — adaptive layout Final " | ".join(segments) → fit_segments(layout_segments, terminal_width()). Low-priority segments (worktree, rate_limits, model, cache, ctx) are dropped first on narrow terminals; face_version and mode_health are sacred and always render. shorten_model_label trims the "(1M context)" suffix so Opus display names fit mid-width terminals without being dropped. test_full_telemetry gained monkeypatch.setenv("COLUMNS", "300") because its "all segments render" assertion would otherwise flake against the pytest 80-col default now that adaptive layout actually runs. Test coverage: - 14 new tests split across four classes: TestWave2ABreathingFaceIntegration (4) TestWave2EContextBarIntegration (3) TestWave2DRainbowIntegration (4) TestWave1DAdaptiveLayoutIntegration (3) - 3 existing tests updated to match the Wave 2-E "[bar] 45%" format - 1 existing test (test_full_telemetry) hardened with COLUMNS=300 Local runs: 1077 passed (full plugin test suite). Manual CLI reproductions confirmed: - Default: ◕‿◕ CB v5.6.0 | Ready 🟢 | ... | [████░░░░░░] 42% | ... - 92% context: ... | [████████▓░] 92%⚠ | ... - RAINBOW=1 PLAN: ... | \x1b[38;2;64;128;255m◇ PLAN\x1b[0m 🟢 | ... - COLUMNS=60: ◕‿◕ CB v5.6.0 | Ready 🔴 | $1.50... | 12m (sacred + cost + duration only) --- .../hooks/codingbuddy-hud.py | 135 ++++++++-- packages/claude-code-plugin/tests/test_hud.py | 237 +++++++++++++++++- 2 files changed, 352 insertions(+), 20 deletions(-) diff --git a/packages/claude-code-plugin/hooks/codingbuddy-hud.py b/packages/claude-code-plugin/hooks/codingbuddy-hud.py index 2a36552c..9de70818 100644 --- a/packages/claude-code-plugin/hooks/codingbuddy-hud.py +++ b/packages/claude-code-plugin/hooks/codingbuddy-hud.py @@ -59,6 +59,59 @@ def _format_velocity_segment(stdin_data, hud_state=None): # type: ignore[misc] def _format_cache_savings(stdin_data): # type: ignore[misc] return "" +# Wave 3b integration: hoist Wave 1-D/2-A/2-D/2-E entry points so +# format_status_line does not pay a sys.modules lookup on every render. +# Each import is guarded separately with a defensive fallback so a +# single broken lib module can never take down the whole status line — +# the outer main() catch-all still emits the minimal safe output. + +# Wave 2-A — breathing buddy face +try: + from hud_buddy import select_face_from_state as _select_face_from_state +except ImportError: # pragma: no cover - defensive + def _select_face_from_state(hud_state): # type: ignore[misc] + return BUDDY_FACE + +# Wave 2-D — mode rainbow ANSI coloring (opt-in via CODINGBUDDY_HUD_RAINBOW) +try: + from hud_rainbow import ( + is_color_enabled as _rainbow_color_enabled, + render_mode_rainbow as _render_mode_rainbow, + ) +except ImportError: # pragma: no cover - defensive + def _rainbow_color_enabled(env=None): # type: ignore[misc] + return False + + def _render_mode_rainbow(mode, *, enabled=None, env=None): # type: ignore[misc] + return mode + +# Wave 2-E — smart context bar visualization +try: + from hud_context_bar import format_context_bar_segment as _format_context_bar +except ImportError: # pragma: no cover - defensive + def _format_context_bar(stdin_data): # type: ignore[misc] + return "" + +# Wave 1-D — adaptive layout engine +try: + from hud_layout import ( + fit_segments as _fit_segments, + terminal_width as _terminal_width, + shorten_model_label as _shorten_model_label, + DEFAULT_SEPARATOR as _LAYOUT_SEP, + ) +except ImportError: # pragma: no cover - defensive + _LAYOUT_SEP = " | " + + def _terminal_width(*, fallback=120): # type: ignore[misc] + return fallback + + def _shorten_model_label(name, *, compact=False): # type: ignore[misc] + return name + + def _fit_segments(segments, width, *, separator=_LAYOUT_SEP): # type: ignore[misc] + return separator.join(t for _, _, t in segments if t) + # Agent eye glyphs from .ai-rules agent definitions. AGENT_GLYPHS = { "act-mode": "\u25c6", # ◆ @@ -474,28 +527,78 @@ def format_status_line( ver_str = f" v{version}" if version else "" - segments = [ - f"{BUDDY_FACE} CB{ver_str}", - f"{mode_label} {health}", - duration, - f"{cost_prefix}{cost:.2f}{velocity_suffix}{savings_suffix}", + # Wave 2-A: dynamic buddy face from hud_state phase/blockerCount. + # Falls back to canonical BUDDY_FACE when hud_state is empty. + buddy_face = _select_face_from_state(hud_state) or BUDDY_FACE + + # Wave 2-E: render context as a visual bar instead of "Ctx:NN%". + # format_context_bar_segment returns "" when context_window is + # absent — keep the legacy text segment as a fallback in that + # case so a stripped-down stdin still shows a percentage. + ctx_segment = _format_context_bar(stdin_data) or f"{ctx_pct:.0f}%" + + # Wave 2-E helper returns e.g. "[██░░░░░░░░] 20%" — the block + # glyphs widen the segment, so assign a dedicated priority slot + # (see SEGMENT_PRIORITY in hud_layout) so adaptive layout can + # drop it first when terminal is tight. + + # Plain-text mode label for layout width calculation. Rainbow + # coloring is applied to the FINAL string after fit_segments + # has assembled it (see below) — injecting ANSI into segments + # would break visible_len accounting. + mode_health_plain = f"{mode_label} {health}" + + # Compact model label: trim the "(1M context)" suffix so the + # segment can fit into mid-width terminals without being dropped + # by fit_segments. + model_segment = _shorten_model_label(display_name) if display_name else "" + + # Build the (name, priority, text) segments consumed by + # hud_layout.fit_segments. Priorities mirror SEGMENT_PRIORITY — + # face_version (0) and mode_health (1) are sacred. + layout_segments = [ + ("face_version", 0, f"{buddy_face} CB{ver_str}"), + ("mode_health", 1, mode_health_plain), + ("cost", 2, f"{cost_prefix}{cost:.2f}{velocity_suffix}{savings_suffix}"), + ("duration", 3, duration), + ("ctx", 4, ctx_segment), ] if cache_segment: - segments.append(cache_segment) - segments.append(f"Ctx:{ctx_pct:.0f}%") - + layout_segments.append(("cache", 5, cache_segment)) + if model_segment: + layout_segments.append(("model", 6, model_segment)) rl = format_rate_limits(stdin_data) if rl: - segments.append(rl) - + layout_segments.append(("rate_limits", 7, rl)) wt = format_worktree(stdin_data) if wt: - segments.append(wt) - - if display_name: - segments.append(display_name) - - line1 = " | ".join(segments) + layout_segments.append(("worktree", 8, wt)) + + # Wave 1-D: priority-driven adaptive layout. fit_segments drops + # the highest-priority-number segments first until the line fits + # within the terminal width. Sacred segments (priority ≤ 1) are + # never dropped; the face_version + mode_health pair therefore + # always renders even in an 80-col terminal. + line1 = _fit_segments(layout_segments, _terminal_width()) + + # Wave 2-D: opt-in ANSI rainbow coloring for the mode label. + # Gated on ``CODINGBUDDY_HUD_RAINBOW=1`` because Claude Code's + # statusLine renderer's support for ANSI is environment-dependent + # — default OFF keeps existing terminals clean. NO_COLOR (per the + # https://no-color.org standard) is honoured transitively via + # hud_rainbow.is_color_enabled even when the opt-in flag is set. + # Only real modes (PLAN/ACT/EVAL/AUTO) are colored — the "Ready" + # fallback label stays plain so existing tests and logs don't + # see it uppercased. + if ( + mode + and os.environ.get("CODINGBUDDY_HUD_RAINBOW", "") == "1" + and _rainbow_color_enabled() + and mode_health_plain in line1 + ): + colored_mode = _render_mode_rainbow(mode, enabled=True) + rainbow_mode_health = f"{colored_mode} {health}" + line1 = line1.replace(mode_health_plain, rainbow_mode_health, 1) focus = hud_state.get("focus") or "" blocker_count = hud_state.get("blockerCount", 0) or 0 diff --git a/packages/claude-code-plugin/tests/test_hud.py b/packages/claude-code-plugin/tests/test_hud.py index f48fecfa..e5990257 100644 --- a/packages/claude-code-plugin/tests/test_hud.py +++ b/packages/claude-code-plugin/tests/test_hud.py @@ -211,7 +211,9 @@ def test_status_line_hides_cache_when_usage_absent(self): stdin = {"context_window": {"used_percentage": 10}} result = hud.format_status_line(stdin, {}, plugins_file=self._NO_PLUGINS) assert "Cache:" not in result - assert "Ctx:10%" in result # other segments still present + # Wave 2-E: context segment renders as "[bar] 10%" rather than "Ctx:10%" + assert "10%" in result + assert "[" in result and "]" in result class TestHealth: @@ -421,7 +423,9 @@ def test_full_output_with_mode(self): assert "PLAN" in result assert "5.1.1" in result assert "~$" in result # estimated (no cost.total_cost_usd) - assert "Ctx:45%" in result + # Wave 2-E: context bar replaces "Ctx:45%" with "[bar] 45%" + assert "45%" in result + assert "[" in result and "]" in result assert "Opus" in result # display_name shown def test_exact_cost_prefix(self): @@ -554,8 +558,13 @@ def test_worktree_shown(self): result = hud.format_status_line(stdin, {}) assert "WT:feat-x" in result - def test_full_telemetry(self): + def test_full_telemetry(self, monkeypatch): """All exact stdin fields present — full telemetry line + badge line.""" + # Wave 1-D adaptive layout drops low-priority segments (worktree, + # rate_limits) on narrow terminals. Force a wide width so this + # "all telemetry renders" assertion is not flaky against the + # pytest default of 80 columns. + monkeypatch.setenv("COLUMNS", "300") stdin = { "model": {"id": "claude-opus-4-6", "display_name": "Opus"}, "cost": {"total_cost_usd": 2.50, "total_duration_ms": 4_980_000}, @@ -706,7 +715,9 @@ def test_pipe_stdin(self): ) assert result.returncode == 0 assert "\u25d5\u203f\u25d5" in result.stdout # ◕‿◕ - assert "Ctx:45%" in result.stdout + # Wave 2-E: context segment renders as "[bar] 45%" rather than "Ctx:45%" + assert "45%" in result.stdout + assert "[" in result.stdout and "]" in result.stdout assert "$0.05" in result.stdout # exact cost def test_pipe_stdin_estimated_cost(self): @@ -837,3 +848,221 @@ def test_all_wave_modules_importable(self): import hud_session # noqa: F401 import hud_version # noqa: F401 import hud_rate_limits # noqa: F401 + + +# ============================================================================ +# Wave 3b integration tests (Wave 1-D / 2-A / 2-D / 2-E wired into +# format_status_line). These close the gap left by commit bd78195 which +# only integrated Wave 2-B/2-C. See docs/codingbuddy/plan/ for the rollout. +# ============================================================================ + + +class TestWave2ABreathingFaceIntegration: + """Wave 2-A: format_status_line picks the buddy face from hud_state.phase.""" + + _NO_PLUGINS = "/tmp/codingbuddy-wave3b-test-nonexistent-plugins.json" + + def test_planning_phase_shows_thinking_face(self): + state = { + "sessionId": "s1", + "phase": "planning", + "currentMode": "PLAN", + } + result = hud.format_status_line( + {"session_id": "s1"}, state, plugins_file=self._NO_PLUGINS + ) + # FACE_THINKING = ◔‿◔ + assert "\u25d4\u203f\u25d4" in result + + def test_executing_phase_shows_active_face(self): + state = { + "sessionId": "s1", + "phase": "executing", + "currentMode": "ACT", + } + result = hud.format_status_line( + {"session_id": "s1"}, state, plugins_file=self._NO_PLUGINS + ) + # FACE_ACTIVE = ◕◡◕ + assert "\u25d5\u25e1\u25d5" in result + + def test_blockers_show_error_face(self): + state = { + "sessionId": "s1", + "phase": "executing", + "blockerCount": 2, + } + result = hud.format_status_line( + {"session_id": "s1"}, state, plugins_file=self._NO_PLUGINS + ) + # FACE_ERROR = ◕︵◕ + assert "\u25d5\ufe35\u25d5" in result + + def test_ready_phase_preserves_idle_face(self): + """Default/idle phase must not change the canonical glyph.""" + state = {"sessionId": "s1", "phase": "ready"} + result = hud.format_status_line( + {"session_id": "s1"}, state, plugins_file=self._NO_PLUGINS + ) + # FACE_IDLE = ◕‿◕ + assert "\u25d5\u203f\u25d5" in result + + +class TestWave2EContextBarIntegration: + """Wave 2-E: Ctx:% segment replaced with visual progress bar.""" + + _NO_PLUGINS = "/tmp/codingbuddy-wave3b-test-nonexistent-plugins.json" + + def test_context_bar_replaces_ctx_text(self): + stdin = { + "session_id": "s1", + "context_window": {"used_percentage": 42}, + } + result = hud.format_status_line( + stdin, {"sessionId": "s1"}, plugins_file=self._NO_PLUGINS + ) + # Context bar uses block-drawing glyph and brackets + assert "[" in result + assert "]" in result + assert "\u2588" in result or "\u2591" in result # █ or ░ + assert "42%" in result + # Old "Ctx:" prefix must not leak back in + assert "Ctx:" not in result + + def test_warning_symbol_above_80_percent(self): + stdin = { + "session_id": "s1", + "context_window": {"used_percentage": 88}, + } + result = hud.format_status_line( + stdin, {"sessionId": "s1"}, plugins_file=self._NO_PLUGINS + ) + assert "\u26a0" in result # ⚠ + + def test_context_absent_falls_back_to_legacy_text(self): + """Missing context_window should still render something percentage-shaped.""" + result = hud.format_status_line( + {"session_id": "s1"}, {"sessionId": "s1"}, plugins_file=self._NO_PLUGINS + ) + assert "0%" in result # either legacy "Ctx:0%" or bar "[...] 0%" + + +class TestWave2DRainbowIntegration: + """Wave 2-D: opt-in ANSI mode coloring via CODINGBUDDY_HUD_RAINBOW.""" + + _NO_PLUGINS = "/tmp/codingbuddy-wave3b-test-nonexistent-plugins.json" + + def test_rainbow_disabled_by_default(self, monkeypatch): + """Default: no ANSI escapes in status line output.""" + monkeypatch.delenv("CODINGBUDDY_HUD_RAINBOW", raising=False) + monkeypatch.delenv("NO_COLOR", raising=False) + stdin = {"session_id": "s1"} + state = {"sessionId": "s1", "currentMode": "PLAN"} + result = hud.format_status_line( + stdin, state, plugins_file=self._NO_PLUGINS + ) + assert "\x1b[" not in result # No ANSI escapes + assert "PLAN" in result # Mode label still present + + def test_rainbow_opt_in_emits_ansi(self, monkeypatch): + """CODINGBUDDY_HUD_RAINBOW=1 activates ANSI coloring for known modes.""" + monkeypatch.setenv("CODINGBUDDY_HUD_RAINBOW", "1") + monkeypatch.delenv("NO_COLOR", raising=False) + stdin = {"session_id": "s1"} + state = {"sessionId": "s1", "currentMode": "PLAN"} + result = hud.format_status_line( + stdin, state, plugins_file=self._NO_PLUGINS + ) + assert "\x1b[" in result + assert "\x1b[0m" in result # RESET present + assert "PLAN" in result # Label substring survives + + def test_no_color_overrides_opt_in(self, monkeypatch): + """NO_COLOR=1 always wins, even with CODINGBUDDY_HUD_RAINBOW=1.""" + monkeypatch.setenv("CODINGBUDDY_HUD_RAINBOW", "1") + monkeypatch.setenv("NO_COLOR", "1") + stdin = {"session_id": "s1"} + state = {"sessionId": "s1", "currentMode": "PLAN"} + result = hud.format_status_line( + stdin, state, plugins_file=self._NO_PLUGINS + ) + assert "\x1b[" not in result + + def test_rainbow_preserves_ready_label_for_no_mode(self, monkeypatch): + """No currentMode → 'Ready' label must remain plain (not uppercased).""" + monkeypatch.setenv("CODINGBUDDY_HUD_RAINBOW", "1") + stdin = {"session_id": "s1"} + state = {"sessionId": "s1"} # no currentMode + result = hud.format_status_line( + stdin, state, plugins_file=self._NO_PLUGINS + ) + assert "Ready" in result + assert "READY" not in result.replace("Ready", "") + + +class TestWave1DAdaptiveLayoutIntegration: + """Wave 1-D: fit_segments drops low-priority segments on narrow terminals.""" + + _NO_PLUGINS = "/tmp/codingbuddy-wave3b-test-nonexistent-plugins.json" + + def test_narrow_terminal_fits_within_width(self, monkeypatch): + """With COLUMNS=60, the status line line1 should not exceed 60 cols.""" + from hud_layout import visible_len + + monkeypatch.setenv("COLUMNS", "60") + stdin = { + "session_id": "s1", + "model": {"id": "claude-opus-4-6", "display_name": "Opus 4.6 (1M context)"}, + "cost": {"total_cost_usd": 1.23, "total_duration_ms": 600000}, + "context_window": { + "used_percentage": 45, + "current_usage": { + "input_tokens": 1000, + "cache_creation_input_tokens": 500, + "cache_read_input_tokens": 2000, + }, + }, + "rate_limits": { + "five_hour": {"used_percentage": 55}, + "seven_day": {"used_percentage": 70}, + }, + } + state = {"sessionId": "s1", "currentMode": "PLAN"} + result = hud.format_status_line( + stdin, state, plugins_file=self._NO_PLUGINS + ) + line1 = result.split("\n")[0] + assert visible_len(line1) <= 60, ( + f"line1 visible width {visible_len(line1)} > 60: {line1!r}" + ) + + def test_sacred_segments_survive_narrow_width(self, monkeypatch): + """Even on ultra-narrow widths, face_version and mode_health remain.""" + monkeypatch.setenv("COLUMNS", "30") + stdin = {"session_id": "s1", "context_window": {"used_percentage": 10}} + state = {"sessionId": "s1", "currentMode": "PLAN"} + result = hud.format_status_line( + stdin, state, plugins_file=self._NO_PLUGINS + ) + line1 = result.split("\n")[0] + assert "CB" in line1 # face_version sacred + assert "PLAN" in line1 or "Ready" in line1 # mode_health sacred + + def test_wide_terminal_keeps_all_segments(self, monkeypatch): + """With plenty of width, no segments are dropped.""" + monkeypatch.setenv("COLUMNS", "300") + stdin = { + "session_id": "s1", + "model": {"id": "claude-opus-4-6", "display_name": "Opus 4.6"}, + "cost": {"total_cost_usd": 1.23, "total_duration_ms": 600000}, + "context_window": {"used_percentage": 45}, + } + state = {"sessionId": "s1", "currentMode": "PLAN"} + result = hud.format_status_line( + stdin, state, plugins_file=self._NO_PLUGINS + ) + line1 = result.split("\n")[0] + # All normal segments present + assert "Opus 4.6" in line1 + assert "$1.23" in line1 + assert "10m" in line1 or "$" in line1 # duration present