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