diff --git a/apps/landing-page/__tests__/setup.test.ts b/apps/landing-page/__tests__/setup.test.ts index 00d1c0c6..1b5e64f8 100644 --- a/apps/landing-page/__tests__/setup.test.ts +++ b/apps/landing-page/__tests__/setup.test.ts @@ -8,7 +8,7 @@ describe('Next.js 16 Project Setup', () => { }); test('Next.js 16.x is installed and locked', () => { - expect(pkg.dependencies.next).toBe('16.1.6'); + expect(pkg.dependencies.next).toBe('16.2.3'); }); test('React 19.x is installed and locked', () => { diff --git a/apps/landing-page/package.json b/apps/landing-page/package.json index 89265a00..a5c557ca 100644 --- a/apps/landing-page/package.json +++ b/apps/landing-page/package.json @@ -32,7 +32,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", - "next": "16.1.6", + "next": "16.2.3", "next-intl": "^4.8.2", "next-themes": "^0.4.6", "prism-react-renderer": "^2.4.1", @@ -56,7 +56,7 @@ "axe-core": "^4.11.1", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", - "eslint-config-next": "16.1.6", + "eslint-config-next": "16.2.3", "happy-dom": "^20.8.8", "jest-axe": "^10.0.0", "madge": "^8.0.0", diff --git a/packages/claude-code-plugin/hooks/codingbuddy-hud.py b/packages/claude-code-plugin/hooks/codingbuddy-hud.py index e72a645b..2a36552c 100644 --- a/packages/claude-code-plugin/hooks/codingbuddy-hud.py +++ b/packages/claude-code-plugin/hooks/codingbuddy-hud.py @@ -14,8 +14,50 @@ import os import sys from datetime import datetime, timezone +from typing import Optional + +# --- lib import bootstrap --- +# statusLine entry script: sys.path insertion here is intentional so +# lib/* imports work when Claude Code invokes `python codingbuddy-hud.py`. +_LIB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") +if _LIB_DIR not in sys.path: + sys.path.insert(0, _LIB_DIR) + +# === test_hud.py compatibility re-exports — DO NOT REMOVE without coordinated test update === +# Narrow the fallback to ImportError only: real logic bugs in lib modules +# (SyntaxError, NameError, AttributeError) must surface immediately instead +# of being silently swallowed by a catch-all. If a lib module fails to import +# entirely, the outer main() try/except at the bottom of this file still +# emits the minimal safe output via the BUDDY_FACE constant. +try: + from hud_buddy import BUDDY_FACE # canonical SSoT via tiny_actor_presets +except ImportError: # pragma: no cover - defensive + BUDDY_FACE = "◕‿◕" # minimal constant for safe-output path + +try: + from hud_rate_limits import format_rate_limits # noqa: F401 re-exported for test_hud.py +except ImportError: # pragma: no cover - defensive + pass # main() catch-all handles absence + +try: + from hud_version import get_fresh_version as _get_fresh_version # backcompat alias +except ImportError: # pragma: no cover - defensive + pass # main() catch-all handles absence + +# Wave 2-B velocity + Wave 2-C cache savings hot-path suffixes for the cost segment. +# Hoisted to module top per perf-1485 H1 so format_status_line avoids a +# sys.modules lookup on every render (~0.47μs saved per call). +try: + from hud_velocity import format_velocity_segment as _format_velocity_segment +except ImportError: # pragma: no cover - defensive + def _format_velocity_segment(stdin_data, hud_state=None): # type: ignore[misc] + return "" -BUDDY_FACE = "\u25d5\u203f\u25d5" # ◕‿◕ +try: + from hud_cache_savings import format_cache_savings as _format_cache_savings +except ImportError: # pragma: no cover - defensive + def _format_cache_savings(stdin_data): # type: ignore[misc] + return "" # Agent eye glyphs from .ai-rules agent definitions. AGENT_GLYPHS = { @@ -303,25 +345,6 @@ def resolve_model_label(stdin_data: dict) -> tuple: return (model_id, display_name) -def format_rate_limits(stdin_data: dict) -> str: - """Format rate-limit info if present. Returns '' when absent.""" - rl = stdin_data.get("rate_limits") - if not rl: - return "" - parts = [] - five = rl.get("five_hour") - if five: - pct = five.get("used_percentage", 0) - parts.append(f"5h:{pct:.0f}%") - seven = rl.get("seven_day") - if seven: - pct = seven.get("used_percentage", 0) - parts.append(f"7d:{pct:.0f}%") - if not parts: - return "" - return "RL:" + ",".join(parts) - - def format_worktree(stdin_data: dict) -> str: """Format worktree name if present. Returns '' when absent.""" wt = stdin_data.get("worktree") @@ -395,45 +418,36 @@ def format_badge_line(agent: str, focus: str, blocker_count) -> str: return " ".join(badges) -def _get_fresh_version(hud_state: dict, *, plugins_file: str = "") -> str: - """Return the most current plugin version. - - Prefers installed_plugins.json (authoritative after updates) - over the hud-state snapshot written at session start. - Pass *plugins_file* explicitly for testing. - """ - try: - lib_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") - if lib_dir not in sys.path: - sys.path.insert(0, lib_dir) - from hud_helpers import read_installed_version - - kwargs = {"plugins_file": plugins_file} if plugins_file else {} - fresh = read_installed_version(**kwargs) - if fresh: - return fresh - except Exception: - pass - return hud_state.get("version", "") - - def format_status_line( stdin_data: dict, hud_state: dict, active_agent: str = "", *, plugins_file: str = "", + plugin_json_file: Optional[str] = None, ) -> str: """Format the statusLine output. Fallback order per field: - version → installed_plugins.json > hud-state.version + version → installed_plugins.json > plugin.json > hud-state.version cost → stdin cost.total_cost_usd > estimate_cost() duration → stdin cost.total_duration_ms > hud-state sessionStartTimestamp agent → stdin agent.name > hud_state.activeAgent > active_agent param model → stdin model.display_name > model.id + + Args: + plugin_json_file: Wave 1-A control for the local ``plugin.json`` + fallback. ``None`` (default) disables tier-2 — matches the + hud_version contract for backwards compatibility. ``""`` + enables the dev-install default path. A non-empty string + overrides the path for tests. ``main()`` passes ``""`` in + production so statusLine always reflects a fresh version. """ - version = _get_fresh_version(hud_state, plugins_file=plugins_file) + version = _get_fresh_version( + hud_state, + plugins_file=plugins_file, + plugin_json_file=plugin_json_file, + ) mode = hud_state.get("currentMode") mode_label = mode if mode else "Ready" @@ -449,13 +463,22 @@ def format_status_line( cost_prefix = "$" if is_exact else "~$" + # Wave 3 integration: append optional Wave 2-B velocity and + # Wave 2-C cache-savings suffixes to the cost segment. Both + # helpers return empty string when they have nothing to show, + # so the downstream segment renders unchanged in the common case. + # Use module-top hoisted imports (see lib fallback block). These helpers + # are guaranteed to return "" on missing data, so no try/except is needed. + velocity_suffix = _format_velocity_segment(stdin_data, hud_state) + savings_suffix = _format_cache_savings(stdin_data) + 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}", + f"{cost_prefix}{cost:.2f}{velocity_suffix}{savings_suffix}", ] if cache_segment: segments.append(cache_segment) @@ -492,9 +515,29 @@ def main(): state_file = os.environ.get("CODINGBUDDY_HUD_STATE_FILE", DEFAULT_STATE_FILE) hud_state = read_state(state_file) + # Wave 1-B: self-heal stale state (e.g. manual-fix marker, + # old timestamp, stdin session mismatch) before rendering so + # the HUD never shows leftover fields from a prior session. + try: + from hud_session import detect_stale_session, heal_stale_state + stdin_session_id = ( + stdin_data.get("session_id") if stdin_data else "" + ) or "" + if detect_stale_session( + hud_state, stdin_session_id=stdin_session_id + ): + hud_state = heal_stale_state(hud_state) + except Exception: + pass # never block rendering on self-heal failure + env_agent = os.environ.get("CODINGBUDDY_ACTIVE_AGENT", "") - output = format_status_line(stdin_data, hud_state, env_agent) + # Pass plugin_json_file="" to enable Wave 1-A dev-install + # plugin.json fallback. Tests opt out by omitting this kwarg + # and relying on the Optional[str]=None default. + output = format_status_line( + stdin_data, hud_state, env_agent, plugin_json_file="" + ) print(output) except Exception: print(f"{BUDDY_FACE} CodingBuddy") diff --git a/packages/claude-code-plugin/hooks/lib/hud_buddy.py b/packages/claude-code-plugin/hooks/lib/hud_buddy.py new file mode 100644 index 00000000..5194e951 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_buddy.py @@ -0,0 +1,148 @@ +"""Buddy face state engine for CodingBuddy statusLine (#1326, Wave 2-A). + +``BUDDY_FACE`` is still canonically defined in +``tiny_actor_presets.BUDDY_FACE`` and re-exported here for import +clarity at the HUD layer. Wave 2-A adds a **state-aware** face +picker so the Buddy appears to "breathe" as the session moves +through its lifecycle phases: + +- **Idle / Ready** → ``◕‿◕`` — resting, calm +- **Thinking / Planning** → ``◔‿◔`` — half-closed eyes, pondering +- **Active / Executing** → ``◕◡◕`` — smiling, working +- **Error / Blocked** → ``◕︵◕`` — concerned, something broke +- **Victory / Completed**→ ``◕ᴗ◕`` — beaming, success + +Priority rules: + +1. ``blocker_count > 0`` always wins (error face) regardless of phase. +2. ``recent_event == "victory"`` wins over phase when set. +3. Otherwise the phase mapping decides. +4. Unknown/empty phase falls back to the canonical idle face. + +Primary entry points: + +- :data:`BUDDY_FACE` — canonical idle glyph (re-exported) +- :data:`FACE_*` — individual state glyphs as constants +- :func:`get_buddy_face` — state-to-glyph lookup +- :func:`select_face_from_state` — HUD-state-dict convenience wrapper +""" +from __future__ import annotations + +from typing import Any, Dict, Optional + +from tiny_actor_presets import BUDDY_FACE # canonical SSoT (idle face) + +# ------------------------------------------------------------------------ +# Face glyph constants +# ------------------------------------------------------------------------ + +#: Default / idle face — ``◕‿◕`` (U+25D5 U+203F U+25D5). +FACE_IDLE: str = BUDDY_FACE + +#: Thinking face — ``◔‿◔`` (half-closed eyes). +FACE_THINKING: str = "\u25d4\u203f\u25d4" + +#: Active face — ``◕◡◕`` (smiling, working). +FACE_ACTIVE: str = "\u25d5\u25e1\u25d5" + +#: Error / blocked face — ``◕︵◕`` (concerned). +FACE_ERROR: str = "\u25d5\ufe35\u25d5" + +#: Victory / completed face — ``◕ᴗ◕`` (beaming). +FACE_VICTORY: str = "\u25d5\u1d17\u25d5" + + +# ------------------------------------------------------------------------ +# Phase → face mapping +# ------------------------------------------------------------------------ + +_PHASE_FACE_MAP: Dict[str, str] = { + "ready": FACE_IDLE, + "planning": FACE_THINKING, + "executing": FACE_ACTIVE, + "evaluating": FACE_THINKING, + "cycling": FACE_ACTIVE, + "completed": FACE_VICTORY, +} + + +# ------------------------------------------------------------------------ +# Public API +# ------------------------------------------------------------------------ + + +def get_buddy_face( + phase: Optional[str] = None, + *, + blocker_count: int = 0, + recent_event: Optional[str] = None, +) -> str: + """Return the buddy face glyph for the given state. + + Priority resolution (highest wins): + + 1. ``blocker_count > 0`` → :data:`FACE_ERROR` + 2. ``recent_event == "victory"`` → :data:`FACE_VICTORY` + 3. Phase lookup against :data:`_PHASE_FACE_MAP` + 4. Fallback: :data:`FACE_IDLE` + + Args: + phase: HUD state phase. One of + ``ready``, ``planning``, ``executing``, ``evaluating``, + ``cycling``, ``completed`` (case-insensitive). Unknown + or empty values fall back to the idle face. + blocker_count: Number of blockers detected. Any positive + value triggers the error face. + recent_event: Optional one-off event marker. Currently only + ``"victory"`` is recognised. + + Returns: + A 3-character glyph string, never empty. + """ + # (1) Error: blockers take precedence + try: + if int(blocker_count) > 0: + return FACE_ERROR + except (TypeError, ValueError): + pass # ignore malformed counter, fall through + + # (2) Victory event beats phase + if recent_event and recent_event.lower() == "victory": + return FACE_VICTORY + + # (3) Phase mapping + if phase: + return _PHASE_FACE_MAP.get(phase.lower(), FACE_IDLE) + + # (4) Fallback + return FACE_IDLE + + +def select_face_from_state(hud_state: Dict[str, Any]) -> str: + """Convenience wrapper that extracts face inputs from a hud_state dict. + + Reads: + + - ``phase`` — HUD phase string + - ``blockerCount`` — integer blocker count (default 0) + - ``lastEvent`` — optional one-off event marker + """ + if not hud_state: + return FACE_IDLE + return get_buddy_face( + phase=hud_state.get("phase"), + blocker_count=hud_state.get("blockerCount", 0) or 0, + recent_event=hud_state.get("lastEvent"), + ) + + +__all__ = [ + "BUDDY_FACE", + "FACE_IDLE", + "FACE_THINKING", + "FACE_ACTIVE", + "FACE_ERROR", + "FACE_VICTORY", + "get_buddy_face", + "select_face_from_state", +] diff --git a/packages/claude-code-plugin/hooks/lib/hud_cache_savings.py b/packages/claude-code-plugin/hooks/lib/hud_cache_savings.py new file mode 100644 index 00000000..3bf79a29 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_cache_savings.py @@ -0,0 +1,119 @@ +"""Cache savings calculator for CodingBuddy statusLine (#1326, Wave 2-C). + +Claude API's prompt caching charges ``cache_read_input_tokens`` at +10% of the base input price — a 90% discount. This module quantifies +that discount so the HUD can surface "how much you saved by caching" +as a badge like ``"💰$4.56 saved"`` appended to the cost segment. + +Primary entry points: + +- :func:`compute_cache_savings` — pure arithmetic helper (tokens + + model_id → dollars saved). +- :func:`format_cache_savings` — end-to-end renderer that reads + Claude Code stdin, extracts the relevant fields, and returns the + formatted badge string (or ``""`` when there is nothing to show). +""" +from __future__ import annotations + +from typing import Any, Dict + +# Money glyph — U+1F4B0 money bag emoji +_MONEY_GLYPH: str = "\U0001f4b0" # 💰 + +# cache_read tokens cost 10% of the input price, so the per-token +# savings equals 90% of the input price. +_CACHE_DISCOUNT: float = 0.90 + +# Minimum dollar savings required to show the badge. Hides noise +# below one cent so the status bar does not flicker on tiny reads. +_MIN_DISPLAY_USD: float = 0.01 + +# Baseline input prices in USD per million tokens. Mirrors the +# ``MODEL_PRICING`` table in ``codingbuddy-hud.py``. +_INPUT_PRICE_PER_M: Dict[str, float] = { + "haiku": 0.80, + "sonnet": 3.00, + "opus": 15.00, +} + +# Sonnet as the safe default when the model family cannot be +# identified. Avoids over-claiming savings on unknown tiers. +_DEFAULT_INPUT_PRICE_PER_M: float = 3.00 + + +def _input_price_per_million(model_id: str) -> float: + """Return the baseline input price (USD per million tokens). + + Case-insensitive substring match against the known family keys. + Falls back to the sonnet tier when no key matches. + """ + if not model_id: + return _DEFAULT_INPUT_PRICE_PER_M + lowered = model_id.lower() + for key, price in _INPUT_PRICE_PER_M.items(): + if key in lowered: + return price + return _DEFAULT_INPUT_PRICE_PER_M + + +def compute_cache_savings( + cache_read_tokens: Any, + model_id: str, +) -> float: + """Return the dollar amount saved by cache reads. + + Formula:: + + savings = cache_read_tokens * (input_price / 1_000_000) * 0.90 + + Defensive coercion: negative or non-numeric inputs return + ``0.0`` so callers never render a "saved -$0.12" surprise when + upstream payloads are malformed. + """ + try: + tokens = int(cache_read_tokens) + except (TypeError, ValueError): + return 0.0 + if tokens <= 0: + return 0.0 + price = _input_price_per_million(model_id) + return (tokens / 1_000_000.0) * price * _CACHE_DISCOUNT + + +def format_cache_savings(stdin_data: Dict[str, Any]) -> str: + """Render the cache savings badge from a stdin payload. + + Output format: + + ``💰$4.56 saved`` + + Returns an empty string when any of the following hold: + + * ``stdin_data`` is empty or has no ``context_window`` + * ``current_usage`` is missing + * ``cache_read_input_tokens`` is zero, absent, or negative + * Computed savings < ``$0.01`` (noise floor) + + Model identification is sourced from ``stdin_data.model.id`` + (or ``display_name`` fallback). Unknown models default to the + sonnet-tier input price so the display still shows a + conservative estimate. + """ + if not stdin_data: + return "" + + ctx = stdin_data.get("context_window") or {} + usage = ctx.get("current_usage") or {} + cache_read = usage.get("cache_read_input_tokens", 0) or 0 + + if not cache_read or (isinstance(cache_read, (int, float)) and cache_read <= 0): + return "" + + model_info = stdin_data.get("model") or {} + model_id = model_info.get("id") or model_info.get("display_name") or "" + + savings = compute_cache_savings(cache_read, model_id) + if savings < _MIN_DISPLAY_USD: + return "" + + return f"{_MONEY_GLYPH}${savings:.2f} saved" diff --git a/packages/claude-code-plugin/hooks/lib/hud_context_bar.py b/packages/claude-code-plugin/hooks/lib/hud_context_bar.py new file mode 100644 index 00000000..eeb2e67a --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_context_bar.py @@ -0,0 +1,136 @@ +"""Smart context bar visualization for CodingBuddy statusLine (#1326, Wave 2-E). + +Renders the context-window usage percentage as a compact visual +progress bar so users can see at a glance how close they are to +overflow: + + ``[████░░░░░░] 42%`` (safe — low usage) + ``[███████░░░] 73%`` (warning — approaching the danger zone) + ``[█████████▓] 92%⚠`` (critical — near exhaustion) + +Primary entry point: :func:`render_context_bar`. + +The bar width is :data:`CONTEXT_BAR_WIDTH` (10 cells by default), +chosen to keep the status line compact while still providing +meaningful resolution (each cell represents ~10% of the budget). + +Thresholds (:data:`CONTEXT_BAR_THRESHOLDS`) drive two visual +signals: + +1. A dark-shade glyph (``▓``) replaces the trailing full block when + usage crosses the *danger* threshold — so the last cell pulses + visually even when the bar looks otherwise full. +2. A ``⚠`` suffix is appended when usage crosses the *warning* + threshold — a distinct text marker that survives greyscale / + monochrome renders. +""" +from __future__ import annotations + +from typing import Any, Dict, Tuple + +# ------------------------------------------------------------------------ +# Constants +# ------------------------------------------------------------------------ + +#: Number of cells in the rendered bar. 10 gives ~10% resolution per cell. +CONTEXT_BAR_WIDTH: int = 10 + +#: (warning, danger, critical) thresholds as percentages. +#: +#: * ``warning`` (80) — append a ``⚠`` suffix +#: * ``danger`` (85) — replace the trailing full block with ``▓`` +#: * ``critical`` (95) — both signals always active +CONTEXT_BAR_THRESHOLDS: Tuple[float, float, float] = (80.0, 85.0, 95.0) + +# Block-drawing glyphs +_FULL = "\u2588" # █ +_EMPTY = "\u2591" # ░ +_DARK = "\u2593" # ▓ + +# Warning suffix +_WARN = "\u26a0" # ⚠ + + +def _clamp_percentage(pct: Any) -> float: + """Coerce an arbitrary value to a percentage in ``[0.0, 100.0]``. + + Non-numeric inputs return ``0.0``. Values above 100 are capped + at 100; values below 0 are floored at 0. + """ + try: + value = float(pct) + except (TypeError, ValueError): + return 0.0 + if value < 0.0: + return 0.0 + if value > 100.0: + return 100.0 + return value + + +def render_context_bar( + used_pct: Any, + *, + width: int = CONTEXT_BAR_WIDTH, +) -> str: + """Render a context-bar string from a usage percentage. + + Output format: + + ``[] %[⚠]`` + + The bar contains ``width`` cells; each cell represents + ``100 / width`` percent. The number of filled cells is + ``round(used_pct / 100 * width)``. When usage crosses the + danger threshold, the last full block becomes ``▓`` to make + the "full" state visually distinct from a true max. When usage + crosses the warning threshold, a trailing ``⚠`` is appended. + + Args: + used_pct: Context-window usage percentage (0-100). + Accepts ``int``/``float``/numeric string. Non-numeric + input renders as ``0%``. + width: Override the bar cell count (tests / layout tuning). + + Returns an empty string when ``width <= 0``. + """ + if width <= 0: + return "" + + pct = _clamp_percentage(used_pct) + warning, danger, _critical = CONTEXT_BAR_THRESHOLDS + + # Number of filled cells (rounded for UX — 5% fills half a cell). + filled = int(round(pct / 100.0 * width)) + if filled < 0: + filled = 0 + if filled > width: + filled = width + + # Build the bar + bar_cells = [_FULL] * filled + [_EMPTY] * (width - filled) + + # Danger glyph replaces the trailing full block + if pct >= danger and filled > 0: + bar_cells[filled - 1] = _DARK + + bar = "".join(bar_cells) + suffix = _WARN if pct >= warning else "" + return f"[{bar}] {pct:.0f}%{suffix}" + + +def format_context_bar_segment(stdin_data: Dict[str, Any]) -> str: + """Render the context bar from a Claude Code stdin payload. + + Extracts ``context_window.used_percentage`` and forwards to + :func:`render_context_bar`. Returns an empty string when the + field is absent — callers can append the result conditionally + without surrounding logic. + """ + if not stdin_data: + return "" + ctx = stdin_data.get("context_window") or {} + pct = ctx.get("used_percentage") + if pct is None: + return "" + return render_context_bar(pct) diff --git a/packages/claude-code-plugin/hooks/lib/hud_layout.py b/packages/claude-code-plugin/hooks/lib/hud_layout.py new file mode 100644 index 00000000..39bd2440 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_layout.py @@ -0,0 +1,229 @@ +"""Adaptive layout engine for CodingBuddy statusLine (#1326, Wave 1-D). + +Provides width-aware segment rendering so the status bar never spills +out of the terminal. The core primitives are: + +- :data:`SEGMENT_PRIORITY` — canonical drop order for Wave 3 integrator +- :data:`SACRED_PRIORITY` — threshold below which segments are never dropped +- :func:`visible_len` — width-aware character count (emoji/CJK = 2 cols) +- :func:`terminal_width` — shutil-backed width detection with fallback +- :func:`shorten_model_label` — compact Claude model-name helper +- :func:`fit_segments` — priority-based assembly with overflow truncation + +Wave 1-D only ships the layout helpers. Wave 3 integrator wires them +into ``format_status_line``; until then the monolith continues to +build its status line inline and this module is consumed only by +the new tests. +""" +from __future__ import annotations + +import re +import shutil +import unicodedata +from typing import List, Tuple + +# ------------------------------------------------------------------------ +# Constants +# ------------------------------------------------------------------------ + +#: Canonical drop order for statusLine segments. Priority is ascending; +#: higher numbers are dropped first when width is tight. Entries at or +#: below :data:`SACRED_PRIORITY` are never dropped. +SEGMENT_PRIORITY: List[Tuple[str, int]] = [ + ("face_version", 0), # sacred: "◕‿◕ CB v5.5.0" + ("mode_health", 1), # sacred: "PLAN 🟢" + ("cost", 2), + ("duration", 3), + ("ctx", 4), + ("cache", 5), + ("model", 6), + ("rate_limits", 7), + ("worktree", 8), +] + +#: Priorities ``<= SACRED_PRIORITY`` are never dropped by :func:`fit_segments`. +SACRED_PRIORITY: int = 1 + +#: Default separator used between segments. +DEFAULT_SEPARATOR: str = " | " + +#: Fallback terminal width when ``shutil.get_terminal_size`` cannot +#: report a real value (tests, pipes, detached TTYs). +FALLBACK_TERMINAL_WIDTH: int = 120 + +#: Single-character ellipsis glyph used for hard truncation. +_ELLIPSIS: str = "\u2026" # … + + +# ------------------------------------------------------------------------ +# Width helpers +# ------------------------------------------------------------------------ + + +def visible_len(s: str) -> int: + """Approximate the visible column count of ``s``. + + Emoji, CJK, and other full-width characters count as 2 columns; + everything else counts as 1. This mirrors how most monospaced + terminals render the corresponding glyphs. + + Note: ANSI escape sequences are NOT stripped. When Wave 2-D adds + ANSI coloring, callers that mix coloring with layout must strip + escapes before passing to this function. + """ + width = 0 + for ch in s: + if unicodedata.east_asian_width(ch) in ("W", "F"): + width += 2 + else: + width += 1 + return width + + +def terminal_width(*, fallback: int = FALLBACK_TERMINAL_WIDTH) -> int: + """Return the current terminal width with a safe fallback. + + Uses ``shutil.get_terminal_size`` and degrades to *fallback* + whenever the call raises or reports a non-positive column count. + """ + try: + size = shutil.get_terminal_size((fallback, 20)) + return size.columns if size.columns > 0 else fallback + except Exception: + return fallback + + +# ------------------------------------------------------------------------ +# Model label helper +# ------------------------------------------------------------------------ + +_CONTEXT_SUFFIX_RE = re.compile(r"\s*\([^)]*context\)\s*$", re.IGNORECASE) +_COMPACT_PATTERN_RE = re.compile(r"(\w+).*?(\d+[KMG])", re.IGNORECASE) + + +def shorten_model_label(name: str, *, compact: bool = False) -> str: + """Produce a compact version of a Claude model display name. + + Normal mode (compact=False, default) just strips the trailing + ``" (1M context)"`` marker so a long display name like + ``"Opus 4.6 (1M context)"`` becomes ``"Opus 4.6"``. + + Compact mode (compact=True) extracts the model family and the + context size into a tight ``Family(NM)`` pattern so the string + fits in very narrow terminals. If no context marker is present, + only the first whitespace-separated token is returned. + + Examples: + + >>> shorten_model_label("Opus 4.6 (1M context)") + 'Opus 4.6' + >>> shorten_model_label("Opus 4.6 (1M context)", compact=True) + 'Opus(1M)' + >>> shorten_model_label("Sonnet 4.5") + 'Sonnet 4.5' + >>> shorten_model_label("Sonnet 4.5", compact=True) + 'Sonnet' + >>> shorten_model_label("") + '' + """ + if not name: + return "" + + if not compact: + return _CONTEXT_SUFFIX_RE.sub("", name).strip() + + match = _COMPACT_PATTERN_RE.match(name) + if match: + return f"{match.group(1)}({match.group(2)})" + + parts = name.split() + return parts[0] if parts else name + + +# ------------------------------------------------------------------------ +# Fit segments +# ------------------------------------------------------------------------ + + +def fit_segments( + segments: List[Tuple[str, int, str]], + width: int, + *, + separator: str = DEFAULT_SEPARATOR, +) -> str: + """Render segments with priority-based drop-until-fit semantics. + + Args: + segments: List of ``(name, priority, text)`` tuples. ``name`` + is a caller-supplied identifier (ignored during render), + ``priority`` is the drop order (higher = dropped first, + 0/1 are sacred), and ``text`` is the literal text. + width: Maximum visible column count. Rendering tries to fit + within this budget by dropping the lowest-priority + (highest number) segments first. + separator: String inserted between kept segments. Defaults to + :data:`DEFAULT_SEPARATOR` (`` | ``). + + Returns: + The assembled status line. When even the sacred segments + (priority ``<= SACRED_PRIORITY``) exceed the budget, the + result is hard-truncated with a trailing U+2026 (``…``). + + Contract: + * Empty text segments are always skipped. + * Priority ≤ SACRED_PRIORITY segments are NEVER dropped. + * Output preserves the caller-provided segment order. + """ + # Drop empty text up-front so they don't contribute to width or + # produce double separators. + non_empty = [(n, p, t) for n, p, t in segments if t] + + def render(segs: List[Tuple[str, int, str]]) -> str: + return separator.join(t for _, _, t in segs) + + # Try rendering everything first — the common case. + line = render(non_empty) + if visible_len(line) <= width: + return line + + # Drop segments from highest priority number down until fit or + # only sacred segments remain. + kept = list(non_empty) + droppable_priorities = sorted( + {p for _, p, _ in kept if p > SACRED_PRIORITY}, + reverse=True, + ) + for p in droppable_priorities: + kept = [s for s in kept if s[1] != p] + line = render(kept) + if visible_len(line) <= width: + return line + + # Even sacred segments alone don't fit — hard truncate. + line = render(kept) + if visible_len(line) > width: + return _hard_truncate(line, width) + return line + + +def _hard_truncate(s: str, width: int) -> str: + """Truncate ``s`` to ``width`` visible columns with trailing ellipsis. + + Walks characters left-to-right until the visible budget (minus + one column reserved for the ``…`` glyph) is consumed. Returns + just the ellipsis when ``width <= 1``. + """ + if width <= 0: + return "" + if width == 1: + return _ELLIPSIS + budget = width - 1 # reserve 1 column for the ellipsis + result: list = [] + cost = 0 + for ch in s: + ch_width = 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 + if cost + ch_width > budget: + break + result.append(ch) + cost += ch_width + return "".join(result) + _ELLIPSIS diff --git a/packages/claude-code-plugin/hooks/lib/hud_rainbow.py b/packages/claude-code-plugin/hooks/lib/hud_rainbow.py new file mode 100644 index 00000000..a4208052 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_rainbow.py @@ -0,0 +1,221 @@ +"""Mode rainbow ANSI coloring for CodingBuddy statusLine (#1326, Wave 2-D). + +Renders the statusLine mode label with per-mode ANSI coloring and a +tier-specific glyph so users can tell at a glance which workflow +mode is active: + + PLAN → ◇ blue (planning — cool, deliberate) + ACT → ◆ green (executing — go) + EVAL → ◈ purple (evaluating — reflective) + AUTO → ◊ rainbow (cycling — energetic gradient) + +Color output honours the ``NO_COLOR`` environment variable +(https://no-color.org) — when it is set to any non-empty value, +:func:`render_mode_rainbow` emits plain text without escape codes so +redirected/logged output stays clean. + +Primary entry points: + +- :func:`is_color_enabled` — check the NO_COLOR env var +- :func:`mode_glyph` — per-mode Unicode glyph +- :func:`gradient_ansi` — character-by-character RGB gradient +- :func:`render_mode_rainbow` — end-to-end mode label renderer +""" +from __future__ import annotations + +import os +from typing import Dict, List, Tuple + +# ------------------------------------------------------------------------ +# Constants +# ------------------------------------------------------------------------ + +# ANSI escape codes +_CSI = "\x1b[" +_RESET = f"{_CSI}0m" + +# Basic 8-bit color codes (38;5;N) for simple modes. +_FG_BLUE = 33 # bright blue +_FG_GREEN = 42 # bright green +_FG_PURPLE = 135 # bright purple +_FG_RED = 196 +_FG_ORANGE = 208 +_FG_YELLOW = 226 +_FG_CYAN = 51 + +# Per-mode glyphs (mirror AGENT_GLYPHS in codingbuddy-hud.py). +_MODE_GLYPHS: Dict[str, str] = { + "PLAN": "\u25c7", # ◇ + "ACT": "\u25c6", # ◆ + "EVAL": "\u25c8", # ◈ + "AUTO": "\u25ca", # ◊ +} + +# Per-mode RGB anchor pairs for gradient rendering. AUTO uses a +# 6-stop rainbow; PLAN/ACT/EVAL use a single solid hue applied to +# every character. +MODE_PALETTE: Dict[str, List[Tuple[int, int, int]]] = { + "PLAN": [(64, 128, 255)], # steady blue + "ACT": [(64, 200, 96)], # steady green + "EVAL": [(180, 96, 220)], # steady purple + "AUTO": [ + (255, 64, 64), # red + (255, 144, 32), # orange + (255, 224, 32), # yellow + (64, 200, 96), # green + (64, 128, 255), # blue + (180, 96, 220), # purple + ], +} + +# Reset sequence exposed for callers (tests). +RESET: str = _RESET + + +# ------------------------------------------------------------------------ +# Environment detection +# ------------------------------------------------------------------------ + + +def is_color_enabled(env: Dict[str, str] = None) -> bool: # type: ignore[assignment] + """Return True when ANSI color output should be produced. + + Honours the ``NO_COLOR`` standard (https://no-color.org): any + non-empty value disables color. When ``env`` is omitted, reads + from :data:`os.environ`. + """ + if env is None: + env = os.environ + value = env.get("NO_COLOR", "") + return not value + + +# ------------------------------------------------------------------------ +# Glyph helper +# ------------------------------------------------------------------------ + + +def mode_glyph(mode: str) -> str: + """Return the Unicode glyph for a mode, or an empty string. + + Mode matching is case-insensitive. Unknown modes return + an empty string so callers can render just the text label. + """ + if not mode: + return "" + return _MODE_GLYPHS.get(mode.upper(), "") + + +# ------------------------------------------------------------------------ +# Gradient renderer +# ------------------------------------------------------------------------ + + +def _rgb_escape(r: int, g: int, b: int) -> str: + """Return the ANSI truecolor foreground escape for ``(r, g, b)``.""" + return f"{_CSI}38;2;{r};{g};{b}m" + + +def gradient_ansi(text: str, palette: List[Tuple[int, int, int]]) -> str: + """Apply a character-by-character gradient to ``text``. + + When ``palette`` has a single color, every character is rendered + in that color. When it has multiple colors, each character is + linearly mapped to a stop on the palette so the first character + is ``palette[0]`` and the last character is ``palette[-1]``. + + Returns ``text`` unchanged (without escapes) when ``palette`` is + empty or ``text`` is empty. + """ + if not text or not palette: + return text + + if len(palette) == 1: + r, g, b = palette[0] + return f"{_rgb_escape(r, g, b)}{text}{_RESET}" + + n_chars = len(text) + n_stops = len(palette) + out: List[str] = [] + for i, ch in enumerate(text): + # Map character index to palette stop (0..n_stops-1) + stop = int(i * n_stops / max(n_chars, 1)) + stop = min(stop, n_stops - 1) + r, g, b = palette[stop] + out.append(f"{_rgb_escape(r, g, b)}{ch}") + out.append(_RESET) + return "".join(out) + + +# ------------------------------------------------------------------------ +# End-to-end renderer +# ------------------------------------------------------------------------ + + +def render_mode_rainbow( + mode: str, + *, + enabled: bool = None, # type: ignore[assignment] + env: Dict[str, str] = None, # type: ignore[assignment] +) -> str: + """Render the mode label with glyph + ANSI color. + + Output format: + + `` `` + + The label text is the uppercased mode name. When color is + enabled, the whole string (glyph + space + label) is wrapped + in the mode's palette via :func:`gradient_ansi`. When color + is disabled, plain text is returned. + + Args: + mode: Workflow mode name (case-insensitive: PLAN/ACT/EVAL/AUTO). + enabled: Explicit override. ``None`` (default) defers to + :func:`is_color_enabled` which honours ``NO_COLOR``. + env: Optional environment override for testing. + + Returns an empty string when ``mode`` is empty. + """ + if not mode: + return "" + + mode_upper = mode.upper() + glyph = mode_glyph(mode_upper) + label = f"{glyph} {mode_upper}" if glyph else mode_upper + + if enabled is None: + enabled = is_color_enabled(env) + if not enabled: + return label + + palette = MODE_PALETTE.get(mode_upper) + if not palette: + return label # unknown mode: plain text + + return gradient_ansi(label, palette) + + +def strip_ansi(s: str) -> str: + """Remove ANSI CSI sequences from ``s`` (helper for tests and layout). + + Simple state machine — recognises ``ESC [`` ... ``m`` escape + runs. Sufficient for the escapes this module emits. + """ + if not s or "\x1b" not in s: + return s + out: List[str] = [] + i = 0 + n = len(s) + while i < n: + ch = s[i] + if ch == "\x1b" and i + 1 < n and s[i + 1] == "[": + # Skip until 'm' + j = i + 2 + while j < n and s[j] != "m": + j += 1 + i = j + 1 + continue + out.append(ch) + i += 1 + return "".join(out) diff --git a/packages/claude-code-plugin/hooks/lib/hud_rate_limits.py b/packages/claude-code-plugin/hooks/lib/hud_rate_limits.py new file mode 100644 index 00000000..c190d7b0 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_rate_limits.py @@ -0,0 +1,93 @@ +"""Rate-limit formatting for CodingBuddy statusLine (#1326, Wave 1-C). + +Wave 0 extracted ``format_rate_limits`` verbatim from the monolith. +Wave 1-C upgrades the badge presentation: + +* severity-icon rendering instead of colon-delimited percentages +* space separator between tiers instead of comma +* defensive float coercion so a non-numeric ``used_percentage`` + (e.g. ``None``, ``"N/A"``) is silently treated as ``0`` instead + of crashing the entire status line +* ``"RL:"`` prefix is retained so downstream surfaces keep a stable + ``startswith/contains`` anchor + +Output example: + + ``RL:5h░13% 7d▓96%`` + +Severity icons (U+2591 / U+2592 / U+2593 block-drawing shades): + +* ``░`` light — ``pct <= 60`` (low / healthy) +* ``▒`` medium — ``60 < pct <= 85`` (warning) +* ``▓`` dark — ``pct > 85`` (critical) +""" +from __future__ import annotations + +from typing import Any, Dict + +# Block-drawing glyphs (U+2591 light, U+2592 medium, U+2593 dark). +_ICON_LOW = "\u2591" # ░ +_ICON_MID = "\u2592" # ▒ +_ICON_HIGH = "\u2593" # ▓ + + +def _severity_icon(pct: float) -> str: + """Return a single block-drawing character reflecting quota usage. + + Tiers: + * ``pct > 85`` → ▓ (dark / critical) + * ``pct > 60`` → ▒ (medium / warning) + * otherwise → ░ (light / healthy) + """ + if pct > 85: + return _ICON_HIGH + if pct > 60: + return _ICON_MID + return _ICON_LOW + + +def _coerce_percentage(raw: Any) -> float: + """Defensively turn a ``used_percentage`` field into a float. + + Accepts ``int``/``float``/numeric string. Returns ``0.0`` for + ``None``, empty strings, non-numeric strings, or any other + unexpected type. Never raises — ``format_rate_limits`` lives on + the statusLine hot path and must degrade gracefully (see Wave 1-A + review HIGH finding for context on why defensive coercion matters). + """ + if raw is None: + return 0.0 + try: + return float(raw) + except (TypeError, ValueError): + return 0.0 + + +def format_rate_limits(stdin_data: Dict[str, Any]) -> str: + """Format Claude Code rate-limit badge with severity icons. + + Returns an empty string when no rate-limit data is supplied so + the badge can be dropped from the status line silently. + """ + rl = stdin_data.get("rate_limits") + if not rl: + return "" + + parts: list = [] + + five = rl.get("five_hour") + if five: + pct = _coerce_percentage(five.get("used_percentage", 0)) + icon = _severity_icon(pct) + parts.append(f"5h{icon}{pct:.0f}%") + + seven = rl.get("seven_day") + if seven: + pct = _coerce_percentage(seven.get("used_percentage", 0)) + icon = _severity_icon(pct) + parts.append(f"7d{icon}{pct:.0f}%") + + if not parts: + return "" + + return "RL:" + " ".join(parts) diff --git a/packages/claude-code-plugin/hooks/lib/hud_session.py b/packages/claude-code-plugin/hooks/lib/hud_session.py new file mode 100644 index 00000000..5fad64e1 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_session.py @@ -0,0 +1,155 @@ +"""Session self-heal and stale state detection (#1326, Wave 1-B). + +Addresses the bug where ``hud-state.json`` retains stale fields from +a previous session (e.g., ``sessionId="manual-fix"``, ``version="5.2.0"``) +and the statusLine renders them as if they were current. This was the +root cause of the bug report: "현재 PLAN 모드인데 ACT로 되어 있고". + +When Claude Code invokes statusLine, stdin carries the real session +ID. If it does not match ``hud_state.sessionId``, the leftover state +is a snapshot from a different session (or a manual edit) and must +be healed before rendering. Additionally, any state older than +``SESSION_STALE_SECONDS`` is treated as stale even without a stdin +mismatch so abandoned sessions do not bleed into fresh ones. + +Healing is a *soft reset*: the cleared fields (currentMode, version, +activeAgent, phase, focus, blockerCount) are overwritten in memory +but the file on disk is not touched — that is the responsibility of +``session-start.py`` or an explicit ``reset_stale_session()`` call. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +# A session older than this is considered stale even when the session +# ID matches. Four hours covers lunch breaks and short meetings but +# catches overnight leftovers and manual edits from yesterday. +SESSION_STALE_SECONDS = 4 * 60 * 60 # 4 hours + +# sessionId values that indicate a not-really-a-session state. Any +# match triggers an immediate heal regardless of other signals. +_REPAIR_MARKERS = frozenset({"", "manual-fix", "unknown", "none"}) + + +def detect_stale_session( + state: Dict[str, Any], + *, + now: Optional[datetime] = None, + stdin_session_id: str = "", +) -> bool: + """Return True if ``state`` should be healed before rendering. + + Staleness indicators (any one triggers stale): + + 1. ``state`` is empty (nothing to heal — returns False). + 2. ``state.sessionId`` is a repair marker (``""``, ``"manual-fix"``, + ``"unknown"``, ``"none"``). + 3. ``stdin_session_id`` is non-empty and differs from + ``state.sessionId`` — caller is from a different session. + 4. ``state.sessionStartTimestamp`` is older than + :data:`SESSION_STALE_SECONDS` or unparseable. + + Args: + state: Current HUD state dict from ``read_hud_state``. + now: Optional clock override for deterministic age tests. + Defaults to ``datetime.now(timezone.utc)``. + stdin_session_id: The current Claude Code session id read + from stdin. Empty string means "not available — skip + mismatch check". + """ + if not state: + return False + + session_id = state.get("sessionId", "") or "" + + # (2) Repair marker check + if session_id in _REPAIR_MARKERS: + return True + + # (3) stdin mismatch check + if stdin_session_id and session_id != stdin_session_id: + return True + + # (4) Age check — prefer `updatedAt` (refreshed on every + # `update_hud_state` write) so long active sessions do not + # falsely flag stale after SESSION_STALE_SECONDS. Fall back + # to `sessionStartTimestamp` when `updatedAt` is absent. + ts = state.get("updatedAt", "") or state.get("sessionStartTimestamp", "") + if ts: + try: + start = datetime.fromisoformat(ts) + if start.tzinfo is None: + start = start.replace(tzinfo=timezone.utc) + current = now or datetime.now(timezone.utc) + age_seconds = (current - start).total_seconds() + if age_seconds > SESSION_STALE_SECONDS: + return True + except (ValueError, TypeError): + # Unparseable timestamp => definitely stale + return True + + return False + + +def heal_stale_state(state: Dict[str, Any]) -> Dict[str, Any]: + """Return a *new* state dict with ephemeral fields cleared. + + Does **not** mutate the input and does **not** write to disk. The + caller is expected to pass the healed copy to ``format_status_line`` + immediately; persisting a fresh baseline is the responsibility of + ``session-start.py`` on the next session boot or of + :func:`reset_stale_session` for callers that want durability now. + + Cleared fields (so the HUD renders a safe default): + + - ``currentMode`` → ``None`` (statusLine shows "Ready") + - ``version`` → ``""`` (hud_version falls back to plugin.json) + - ``activeAgent`` → ``None`` + - ``phase`` → ``"ready"`` + - ``focus`` → ``None`` + - ``blockerCount``→ ``0`` + + Preserved fields: + + - ``sessionId`` (so debugging can see what was there) + - ``sessionStartTimestamp`` (for audit / forensics) + - Any other field not listed above + """ + healed: Dict[str, Any] = dict(state) + healed["currentMode"] = None + healed["version"] = "" + healed["activeAgent"] = None + healed["phase"] = "ready" + healed["focus"] = None + healed["blockerCount"] = 0 + return healed + + +def reset_stale_session(state_file: str) -> None: + """Persist a healed copy of ``state_file`` to disk. + + Reads the current state, runs :func:`detect_stale_session` on it, + and if stale, writes the healed copy via ``hud_state.update_hud_state``. + Intended for call sites that need durable healing (e.g., session + boot). No-ops silently on any failure so it never blocks the caller. + """ + try: + from hud_state import read_hud_state, update_hud_state + + current = read_hud_state(state_file, fill_defaults=False) + if not detect_stale_session(current): + return + healed = heal_stale_state(current) + # update_hud_state merges kwargs — only pass the fields we healed + update_hud_state( + state_file=state_file, + currentMode=healed["currentMode"], + version=healed["version"], + activeAgent=healed["activeAgent"], + phase=healed["phase"], + focus=healed["focus"], + blockerCount=healed["blockerCount"], + ) + except Exception: + pass diff --git a/packages/claude-code-plugin/hooks/lib/hud_velocity.py b/packages/claude-code-plugin/hooks/lib/hud_velocity.py new file mode 100644 index 00000000..9e81185d --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_velocity.py @@ -0,0 +1,189 @@ +"""Cost velocity indicator for CodingBuddy statusLine (#1326, Wave 2-B). + +Shows how fast the session is burning dollars by rendering a +per-minute spend rate next to the absolute cost. Users can see at +a glance whether they're on a slow planning pass ($0.01/min) or a +heavy refactor burn ($0.50/min). + +Output format:: + + $1.23↗$0.08/m + +- ``$1.23`` — absolute cost so far (passed through unchanged) +- ``↗/🔥/💤`` — trend glyph reflecting the burn rate +- ``$0.08/m`` — computed spend rate (USD per minute) + +Wave 2-B ships a **stateless** session-average rate: ``rate = cost / +elapsed_minutes`` derived from Claude Code's own stdin ``cost`` and +``duration_ms`` fields. A richer windowed/ring-buffer implementation +can be added later without changing the public API; the stateless +version is sufficient for UX purposes and avoids touching the +``hud_state.py`` schema. + +Primary entry points: + +- :func:`compute_spend_rate` — pure arithmetic helper +- :func:`trend_glyph` — map rate → visual tier +- :func:`format_velocity_segment` — end-to-end renderer +- :func:`format_cost_with_velocity` — cost prefix + velocity suffix +""" +from __future__ import annotations + +from typing import Any, Dict, Optional + +# ------------------------------------------------------------------------ +# Trend tier thresholds (USD per minute) +# ------------------------------------------------------------------------ + +#: Below this rate the session is considered idle / coasting. +TREND_IDLE_MAX: float = 0.01 + +#: Above this rate the session is a hot burn ("🔥"). +TREND_HOT_MIN: float = 0.20 + +# ------------------------------------------------------------------------ +# Glyphs +# ------------------------------------------------------------------------ + +_GLYPH_HOT = "\U0001f525" # 🔥 +_GLYPH_RISING = "\u2197" # ↗ +_GLYPH_STEADY = "\u2192" # → (sideways arrow for low steady rate) +_GLYPH_IDLE = "\U0001f4a4" # 💤 (zzz face) + + +def compute_spend_rate(cost_usd: Any, duration_ms: Any) -> float: + """Return the session spend rate in USD per minute. + + Formula:: + + rate = cost_usd / (duration_ms / 60_000) + + Defensive coercion: + + * Non-numeric or negative inputs return ``0.0``. + * ``duration_ms <= 0`` returns ``0.0`` (avoids divide-by-zero). + * ``cost_usd == 0`` returns ``0.0`` regardless of duration. + """ + try: + cost = float(cost_usd) + duration = float(duration_ms) + except (TypeError, ValueError): + return 0.0 + if cost <= 0 or duration <= 0: + return 0.0 + minutes = duration / 60_000.0 + if minutes <= 0: + return 0.0 + return cost / minutes + + +def trend_glyph(rate_usd_per_min: float) -> str: + """Return a glyph reflecting the spend tier. + + Tiers: + + * ``rate >= TREND_HOT_MIN`` → 🔥 (hot burn) + * ``rate >= TREND_IDLE_MAX`` → ↗ (rising / normal) + * ``rate > 0`` → → (steady, very low) + * ``rate <= 0`` → 💤 (idle, no meaningful rate) + """ + try: + rate = float(rate_usd_per_min) + except (TypeError, ValueError): + return _GLYPH_IDLE + if rate >= TREND_HOT_MIN: + return _GLYPH_HOT + if rate >= TREND_IDLE_MAX: + return _GLYPH_RISING + if rate > 0: + return _GLYPH_STEADY + return _GLYPH_IDLE + + +def format_velocity_segment( + stdin_data: Dict[str, Any], + hud_state: Optional[Dict[str, Any]] = None, +) -> str: + """Render the velocity suffix like ``↗$0.08/m``. + + Reads ``stdin_data.cost.total_cost_usd`` and + ``stdin_data.cost.total_duration_ms`` — both optional. Falls + back to ``hud_state.sessionStartTimestamp`` when stdin does not + supply the duration (useful when Claude Code omits the cost + payload early in a session). + + Returns an empty string when insufficient data is available to + compute a meaningful rate so callers can conditionally append + without extra guards. + """ + if not stdin_data: + return "" + + cost_info = stdin_data.get("cost") or {} + cost_usd = cost_info.get("total_cost_usd") + duration_ms = cost_info.get("total_duration_ms") + + if cost_usd is None: + return "" + + # When stdin doesn't carry duration, try computing from hud_state. + if duration_ms is None and hud_state: + duration_ms = _duration_ms_from_state(hud_state) + + if duration_ms is None: + return "" + + rate = compute_spend_rate(cost_usd, duration_ms) + if rate <= 0: + return "" + + glyph = trend_glyph(rate) + return f"{glyph}${rate:.2f}/m" + + +def format_cost_with_velocity( + cost_usd: Any, + stdin_data: Dict[str, Any], + hud_state: Optional[Dict[str, Any]] = None, + *, + is_exact: bool = True, +) -> str: + """Render the full cost segment with velocity appended. + + Output format: + + ``$1.23↗$0.08/m`` (when velocity is available) + ``$1.23`` (when velocity cannot be computed) + ``~$1.23↗$0.08/m`` (when cost is an estimate) + + Args: + cost_usd: Absolute session cost. Non-numeric → ``$0.00``. + stdin_data: Claude Code stdin payload (drives velocity). + hud_state: Optional HUD state for duration fallback. + is_exact: When ``False``, prefix with ``~`` to signal estimation. + """ + try: + cost = float(cost_usd) + except (TypeError, ValueError): + cost = 0.0 + prefix = "$" if is_exact else "~$" + velocity = format_velocity_segment(stdin_data, hud_state) + return f"{prefix}{cost:.2f}{velocity}" + + +def _duration_ms_from_state(hud_state: Dict[str, Any]) -> Optional[float]: + """Compute elapsed milliseconds from hud_state.sessionStartTimestamp.""" + ts = hud_state.get("sessionStartTimestamp", "") + if not ts: + return None + try: + from datetime import datetime, timezone + + start = datetime.fromisoformat(ts) + if start.tzinfo is None: + start = start.replace(tzinfo=timezone.utc) + now = datetime.now(timezone.utc) + delta = (now - start).total_seconds() * 1000.0 + return delta if delta > 0 else None + except (ValueError, TypeError): + return None diff --git a/packages/claude-code-plugin/hooks/lib/hud_version.py b/packages/claude-code-plugin/hooks/lib/hud_version.py new file mode 100644 index 00000000..35dac186 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_version.py @@ -0,0 +1,111 @@ +"""Version resolution for CodingBuddy statusLine (#1326, #1464 Wave 1-A). + +Wave 1-A strengthens the version resolution chain with a local +``plugin.json`` fallback so the HUD never shows a stale snapshot after +a plugin update even when ``installed_plugins.json`` is missing or +cannot be parsed. + +The public entry point is :func:`get_fresh_version`. ``codingbuddy-hud`` +calls it internally from ``format_status_line``; callers pass the +current ``hud_state`` dict and optional path overrides used by tests. + +Resolution chain (first non-empty result wins): + +1. ``installed_plugins.json`` — authoritative after ``/plugin update`` + (global Claude Code plugin registry). +2. ``../.claude-plugin/plugin.json`` — deterministic via ``__file__`` + relative path, authoritative for dev installs where the plugin is + running from a git checkout. +3. ``hud_state.get("version", "")`` — snapshot written at session + start (may be stale, last resort). +""" +from __future__ import annotations + +import json +import os +from typing import Any, Dict, Optional + + +def _default_plugin_json_path() -> str: + """Resolve ``plugin.json`` relative to this module's location. + + ``hud_version.py`` lives at + ``packages/claude-code-plugin/hooks/lib/hud_version.py``. + ``plugin.json`` lives at + ``packages/claude-code-plugin/.claude-plugin/plugin.json``. + So we walk up two levels (``lib/`` -> ``hooks/`` -> package root) + and then descend into ``.claude-plugin/``. + """ + here = os.path.dirname(os.path.abspath(__file__)) + return os.path.normpath( + os.path.join(here, "..", "..", ".claude-plugin", "plugin.json") + ) + + +def _read_local_plugin_json(path: str) -> str: + """Read ``plugin.json`` and return its ``version`` field. + + Returns an empty string on any failure (missing file, parse error, + missing key). Never raises — caller must be able to skip silently. + """ + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + v = data.get("version") + return v if isinstance(v, str) else "" + except Exception: + return "" + + +def get_fresh_version( + hud_state: Dict[str, Any], + *, + plugins_file: str = "", + plugin_json_file: Optional[str] = None, +) -> str: + """Return the freshest known plugin version string. + + Args: + hud_state: Current HUD state dict (supplies the final fallback + ``version`` field). + plugins_file: Optional override for the + ``installed_plugins.json`` path, used by tests. + plugin_json_file: Local ``plugin.json`` fallback control: + + * ``None`` (default) — tier-2 fallback is **disabled**. + Only ``installed_plugins.json`` and ``hud_state`` are + consulted. This keeps the signature backwards-compatible + with callers that do not opt in. + * ``""`` — use the default dev-install path resolved from + ``__file__`` (i.e. ``../.claude-plugin/plugin.json``). + ``format_status_line`` passes this in production so + statusLine always reflects a fresh local version. + * non-empty string — treat as an explicit file path + override, used by the test suite for fixture files. + + Notes: + ``hud_helpers`` is imported lazily inside the function body to + preserve the hot-path resilience of the original monolith. If + ``hud_helpers`` is temporarily broken (e.g. mid-wave refactor), + the statusLine still renders via the later fallbacks instead + of crashing at module load. + """ + # 1. Global installed_plugins.json (authoritative after /plugin update) + try: + from hud_helpers import read_installed_version # lazy for resilience + kwargs = {"plugins_file": plugins_file} if plugins_file else {} + fresh = read_installed_version(**kwargs) + if fresh: + return fresh + except Exception: + pass + + # 2. Local plugin.json (opt-in: None disables this tier entirely) + if plugin_json_file is not None: + path_to_try = plugin_json_file or _default_plugin_json_path() + local = _read_local_plugin_json(path_to_try) + if local: + return local + + # 3. hud-state snapshot (may be stale, last resort) + return hud_state.get("version", "") diff --git a/packages/claude-code-plugin/tests/test_hud.py b/packages/claude-code-plugin/tests/test_hud.py index 320ee4d3..f48fecfa 100644 --- a/packages/claude-code-plugin/tests/test_hud.py +++ b/packages/claude-code-plugin/tests/test_hud.py @@ -366,7 +366,9 @@ def test_no_rate_limits(self): def test_five_hour_only(self): stdin = {"rate_limits": {"five_hour": {"used_percentage": 23.5}}} - assert hud.format_rate_limits(stdin) == "RL:5h:24%" + # Wave 1-C: severity icon replaces colon separator + # 23.5% ≤ 60 → U+2591 ░ (low) + assert hud.format_rate_limits(stdin) == "RL:5h\u259124%" def test_both_limits(self): stdin = {"rate_limits": { @@ -374,8 +376,9 @@ def test_both_limits(self): "seven_day": {"used_percentage": 40}, }} result = hud.format_rate_limits(stdin) - assert "5h:10%" in result - assert "7d:40%" in result + # Wave 1-C: severity icon replaces colon; space replaces comma + assert "5h\u259110%" in result + assert "7d\u259140%" in result class TestFormatWorktree: @@ -543,7 +546,8 @@ def test_rate_limits_shown(self): }} result = hud.format_status_line(stdin, {}) assert "RL:" in result - assert "5h:50%" in result + # Wave 1-C: severity icon replaces colon (50% ≤ 60 → ░ low) + assert "5h\u259150%" in result def test_worktree_shown(self): stdin = {"worktree": {"name": "feat-x"}} @@ -739,3 +743,97 @@ def test_empty_stdin_fallback(self): ) assert result.returncode == 0 assert "\u25d5\u203f\u25d5" in result.stdout + + +# ========================= Wave 3 Integration Tests ======================== + + +class TestWave3Integration: + """End-to-end integration regression for Wave 2-B/2-C cost suffixes.""" + + _NO_PLUGINS = "/tmp/_nonexistent_plugins_wave3.json" + + def test_velocity_suffix_present_when_cost_and_duration_available(self): + """Wave 2-B velocity suffix appears after the cost segment.""" + stdin = { + "cost": {"total_cost_usd": 1.0, "total_duration_ms": 60_000}, + "model": {"id": "claude-sonnet", "display_name": "Sonnet"}, + "context_window": {"used_percentage": 5}, + } + state = { + "version": "5.5.0", + "sessionStartTimestamp": datetime.now(timezone.utc).isoformat(), + "currentMode": "PLAN", + } + result = hud.format_status_line( + stdin, state, plugins_file=self._NO_PLUGINS + ) + # $1.00 over 60s → $1.00/m (hot burn tier) + assert "$1.00" in result + assert "/m" in result + + def test_savings_suffix_present_when_cache_reads_exist(self): + """Wave 2-C cache savings suffix appears after the cost segment.""" + stdin = { + "cost": {"total_cost_usd": 1.0, "total_duration_ms": 60_000}, + "model": {"id": "claude-opus"}, + "context_window": { + "used_percentage": 50, + "current_usage": { + "input_tokens": 1000, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1_000_000, + }, + }, + } + state = { + "version": "5.5.0", + "sessionStartTimestamp": datetime.now(timezone.utc).isoformat(), + "currentMode": "PLAN", + } + result = hud.format_status_line( + stdin, state, plugins_file=self._NO_PLUGINS + ) + # 1M cache_read on opus → $13.50 saved + assert "saved" in result + assert "\U0001f4b0" in result # 💰 + + def test_no_velocity_when_cost_missing(self): + """Velocity suffix is omitted when the cost payload is absent.""" + stdin = {"context_window": {"used_percentage": 5}} + state = {"version": "5.5.0", "currentMode": "PLAN"} + result = hud.format_status_line( + stdin, state, plugins_file=self._NO_PLUGINS + ) + assert "/m" not in result + + def test_no_savings_when_cache_reads_zero(self): + """Savings suffix is omitted when there are no cache reads.""" + stdin = { + "cost": {"total_cost_usd": 1.0, "total_duration_ms": 60_000}, + "context_window": { + "used_percentage": 5, + "current_usage": { + "input_tokens": 1000, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + }, + }, + } + state = {"version": "5.5.0", "currentMode": "PLAN"} + result = hud.format_status_line( + stdin, state, plugins_file=self._NO_PLUGINS + ) + assert "saved" not in result + + def test_all_wave_modules_importable(self): + """Sanity: every Wave 2 module is importable through lib/.""" + import hud_buddy # noqa: F401 + import hud_velocity # noqa: F401 + import hud_cache_savings # noqa: F401 + import hud_rainbow # noqa: F401 + import hud_context_bar # noqa: F401 + import hud_layout # noqa: F401 + import hud_session # noqa: F401 + import hud_version # noqa: F401 + import hud_rate_limits # noqa: F401 diff --git a/packages/claude-code-plugin/tests/test_hud_buddy.py b/packages/claude-code-plugin/tests/test_hud_buddy.py new file mode 100644 index 00000000..f64b21c9 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_buddy.py @@ -0,0 +1,199 @@ +"""Sanity test for hud_buddy re-export (Wave 0 / #1463).""" +import importlib +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +import hud_buddy # noqa: E402 +from tiny_actor_presets import BUDDY_FACE as CANONICAL_BUDDY_FACE # noqa: E402 + + +def test_reexport_is_canonical_ssot(): + """hud_buddy.BUDDY_FACE must be the canonical tiny_actor_presets.BUDDY_FACE.""" + assert hud_buddy.BUDDY_FACE is CANONICAL_BUDDY_FACE + + +def test_value_matches_glyph(): + """Sanity: the face is the three-char smiley.""" + assert hud_buddy.BUDDY_FACE == "\u25d5\u203f\u25d5" + + +def test_reexport_identity_from_codingbuddy_hud(): + """Lock: hud.BUDDY_FACE is the same object (re-export chain intact).""" + hud_main = importlib.import_module("codingbuddy-hud") + assert hud_main.BUDDY_FACE is hud_buddy.BUDDY_FACE + assert hud_main.BUDDY_FACE is CANONICAL_BUDDY_FACE + + +# ========================= Wave 2-A: face state engine ====================== + + +def test_face_constants_defined(): + """All five named faces are distinct non-empty strings.""" + faces = { + hud_buddy.FACE_IDLE, + hud_buddy.FACE_THINKING, + hud_buddy.FACE_ACTIVE, + hud_buddy.FACE_ERROR, + hud_buddy.FACE_VICTORY, + } + assert len(faces) == 5 + for face in faces: + assert face # non-empty + assert len(face) >= 3 # 3-char glyphs + + +def test_face_idle_matches_canonical_buddy(): + """FACE_IDLE is the canonical buddy face.""" + assert hud_buddy.FACE_IDLE is hud_buddy.BUDDY_FACE + + +# --- get_buddy_face: phase mapping --- + + +def test_get_face_ready_phase_is_idle(): + assert hud_buddy.get_buddy_face("ready") == hud_buddy.FACE_IDLE + + +def test_get_face_planning_phase_is_thinking(): + assert hud_buddy.get_buddy_face("planning") == hud_buddy.FACE_THINKING + + +def test_get_face_executing_phase_is_active(): + assert hud_buddy.get_buddy_face("executing") == hud_buddy.FACE_ACTIVE + + +def test_get_face_evaluating_phase_is_thinking(): + assert hud_buddy.get_buddy_face("evaluating") == hud_buddy.FACE_THINKING + + +def test_get_face_cycling_phase_is_active(): + assert hud_buddy.get_buddy_face("cycling") == hud_buddy.FACE_ACTIVE + + +def test_get_face_completed_phase_is_victory(): + assert hud_buddy.get_buddy_face("completed") == hud_buddy.FACE_VICTORY + + +def test_get_face_case_insensitive(): + assert hud_buddy.get_buddy_face("PLANNING") == hud_buddy.FACE_THINKING + assert hud_buddy.get_buddy_face("Executing") == hud_buddy.FACE_ACTIVE + + +def test_get_face_unknown_phase_falls_back_to_idle(): + assert hud_buddy.get_buddy_face("waiting") == hud_buddy.FACE_IDLE + assert hud_buddy.get_buddy_face("unknown") == hud_buddy.FACE_IDLE + + +def test_get_face_empty_phase_is_idle(): + assert hud_buddy.get_buddy_face("") == hud_buddy.FACE_IDLE + + +def test_get_face_none_phase_is_idle(): + assert hud_buddy.get_buddy_face(None) == hud_buddy.FACE_IDLE + + +# --- get_buddy_face: priority rules --- + + +def test_blocker_count_beats_phase(): + """Any positive blocker_count triggers the error face.""" + assert ( + hud_buddy.get_buddy_face("executing", blocker_count=1) + == hud_buddy.FACE_ERROR + ) + + +def test_blocker_count_zero_does_not_trigger(): + assert ( + hud_buddy.get_buddy_face("executing", blocker_count=0) + == hud_buddy.FACE_ACTIVE + ) + + +def test_blocker_count_beats_victory(): + """Even a victory event yields error when blockers are present.""" + assert ( + hud_buddy.get_buddy_face( + "completed", blocker_count=3, recent_event="victory" + ) + == hud_buddy.FACE_ERROR + ) + + +def test_blocker_count_malformed_ignored(): + """Non-numeric blocker_count falls through to phase mapping.""" + assert ( + hud_buddy.get_buddy_face("planning", blocker_count="abc") # type: ignore[arg-type] + == hud_buddy.FACE_THINKING + ) + + +def test_recent_event_victory_beats_phase(): + """Victory event wins over phase when no blockers.""" + assert ( + hud_buddy.get_buddy_face("executing", recent_event="victory") + == hud_buddy.FACE_VICTORY + ) + + +def test_recent_event_case_insensitive(): + assert ( + hud_buddy.get_buddy_face("planning", recent_event="VICTORY") + == hud_buddy.FACE_VICTORY + ) + + +def test_recent_event_unknown_ignored(): + assert ( + hud_buddy.get_buddy_face("planning", recent_event="foobar") + == hud_buddy.FACE_THINKING + ) + + +# --- select_face_from_state --- + + +def test_select_face_from_empty_state(): + assert hud_buddy.select_face_from_state({}) == hud_buddy.FACE_IDLE + + +def test_select_face_from_none_state(): + assert hud_buddy.select_face_from_state(None) == hud_buddy.FACE_IDLE # type: ignore[arg-type] + + +def test_select_face_from_planning_state(): + state = {"phase": "planning", "blockerCount": 0} + assert hud_buddy.select_face_from_state(state) == hud_buddy.FACE_THINKING + + +def test_select_face_from_blocked_state(): + state = {"phase": "executing", "blockerCount": 2} + assert hud_buddy.select_face_from_state(state) == hud_buddy.FACE_ERROR + + +def test_select_face_from_victory_state(): + state = {"phase": "completed", "blockerCount": 0, "lastEvent": "victory"} + assert hud_buddy.select_face_from_state(state) == hud_buddy.FACE_VICTORY + + +def test_select_face_from_completed_state_without_victory_marker(): + state = {"phase": "completed", "blockerCount": 0} + assert hud_buddy.select_face_from_state(state) == hud_buddy.FACE_VICTORY + + +# --- __all__ exports --- + + +def test_public_api_exported(): + assert "BUDDY_FACE" in hud_buddy.__all__ + assert "get_buddy_face" in hud_buddy.__all__ + assert "select_face_from_state" in hud_buddy.__all__ + for name in ("FACE_IDLE", "FACE_THINKING", "FACE_ACTIVE", "FACE_ERROR", "FACE_VICTORY"): + assert name in hud_buddy.__all__ diff --git a/packages/claude-code-plugin/tests/test_hud_cache_savings.py b/packages/claude-code-plugin/tests/test_hud_cache_savings.py new file mode 100644 index 00000000..877b79bb --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_cache_savings.py @@ -0,0 +1,210 @@ +"""Behavior tests for hud_cache_savings (Wave 2-C / #1326).""" +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +import hud_cache_savings # noqa: E402 + +_MONEY = "\U0001f4b0" # 💰 + + +# --------------------------- _input_price_per_million ---------------------- + + +def test_input_price_haiku(): + assert hud_cache_savings._input_price_per_million("claude-haiku-4-5") == 0.80 + + +def test_input_price_sonnet(): + assert hud_cache_savings._input_price_per_million("claude-sonnet-4-6") == 3.00 + + +def test_input_price_opus(): + assert hud_cache_savings._input_price_per_million("claude-opus-4-6") == 15.00 + + +def test_input_price_unknown_defaults_to_sonnet(): + assert hud_cache_savings._input_price_per_million("gpt-4") == 3.00 + + +def test_input_price_empty_defaults(): + assert hud_cache_savings._input_price_per_million("") == 3.00 + + +def test_input_price_case_insensitive(): + assert hud_cache_savings._input_price_per_million("CLAUDE-OPUS-4") == 15.00 + + +# --------------------------- compute_cache_savings ------------------------ + + +def test_compute_zero_tokens_returns_zero(): + assert hud_cache_savings.compute_cache_savings(0, "opus") == 0.0 + + +def test_compute_negative_tokens_returns_zero(): + assert hud_cache_savings.compute_cache_savings(-100, "opus") == 0.0 + + +def test_compute_non_numeric_returns_zero(): + assert hud_cache_savings.compute_cache_savings("abc", "opus") == 0.0 + assert hud_cache_savings.compute_cache_savings(None, "opus") == 0.0 + + +def test_compute_opus_savings(): + """1M cache_read tokens on opus → 1M * $15/M * 0.9 = $13.50 saved.""" + result = hud_cache_savings.compute_cache_savings(1_000_000, "claude-opus") + assert abs(result - 13.50) < 0.001 + + +def test_compute_sonnet_savings(): + """1M cache_read tokens on sonnet → 1M * $3/M * 0.9 = $2.70 saved.""" + result = hud_cache_savings.compute_cache_savings(1_000_000, "claude-sonnet") + assert abs(result - 2.70) < 0.001 + + +def test_compute_haiku_savings(): + """1M cache_read tokens on haiku → 1M * $0.80/M * 0.9 = $0.72 saved.""" + result = hud_cache_savings.compute_cache_savings(1_000_000, "claude-haiku") + assert abs(result - 0.72) < 0.001 + + +def test_compute_scales_linearly(): + """Double the tokens → double the savings.""" + a = hud_cache_savings.compute_cache_savings(100_000, "opus") + b = hud_cache_savings.compute_cache_savings(200_000, "opus") + assert abs(b - 2 * a) < 0.001 + + +def test_compute_numeric_string_accepted(): + """Numeric string coerced via int().""" + result = hud_cache_savings.compute_cache_savings("500000", "sonnet") + assert result > 0 + + +# --------------------------- format_cache_savings ------------------------- + + +def test_format_empty_stdin_returns_empty(): + assert hud_cache_savings.format_cache_savings({}) == "" + + +def test_format_no_context_window_returns_empty(): + assert hud_cache_savings.format_cache_savings({"cost": {}}) == "" + + +def test_format_no_current_usage_returns_empty(): + stdin = {"context_window": {}} + assert hud_cache_savings.format_cache_savings(stdin) == "" + + +def test_format_zero_cache_read_returns_empty(): + stdin = { + "context_window": { + "current_usage": {"cache_read_input_tokens": 0} + } + } + assert hud_cache_savings.format_cache_savings(stdin) == "" + + +def test_format_missing_cache_read_returns_empty(): + stdin = { + "context_window": { + "current_usage": {"input_tokens": 1000} + } + } + assert hud_cache_savings.format_cache_savings(stdin) == "" + + +def test_format_below_one_cent_returns_empty(): + """Tiny savings (< $0.01) are hidden to avoid flicker.""" + stdin = { + "context_window": { + "current_usage": {"cache_read_input_tokens": 100} + }, + "model": {"id": "claude-sonnet"}, + } + # 100 tokens * $3/M * 0.9 = $0.00027 → below threshold + result = hud_cache_savings.format_cache_savings(stdin) + assert result == "" + + +def test_format_meaningful_savings_opus(): + """500K cache_read tokens on opus → $6.75 saved.""" + stdin = { + "context_window": { + "current_usage": {"cache_read_input_tokens": 500_000} + }, + "model": {"id": "claude-opus-4-6"}, + } + result = hud_cache_savings.format_cache_savings(stdin) + assert result.startswith(_MONEY) + assert "6.75" in result + assert "saved" in result + + +def test_format_uses_display_name_fallback(): + """When model.id is empty, fall back to display_name for pricing.""" + stdin = { + "context_window": { + "current_usage": {"cache_read_input_tokens": 1_000_000} + }, + "model": {"display_name": "Opus 4.6"}, + } + result = hud_cache_savings.format_cache_savings(stdin) + assert "13.50" in result + + +def test_format_unknown_model_uses_sonnet_default(): + """Unknown model → sonnet-tier pricing ($2.70 per 1M tokens).""" + stdin = { + "context_window": { + "current_usage": {"cache_read_input_tokens": 1_000_000} + }, + "model": {"id": "some-unknown"}, + } + result = hud_cache_savings.format_cache_savings(stdin) + assert "2.70" in result + + +def test_format_uses_money_glyph(): + stdin = { + "context_window": { + "current_usage": {"cache_read_input_tokens": 1_000_000} + }, + "model": {"id": "opus"}, + } + result = hud_cache_savings.format_cache_savings(stdin) + assert result.startswith(_MONEY) + + +def test_format_two_decimal_places(): + """Output always has 2 decimal places.""" + stdin = { + "context_window": { + "current_usage": {"cache_read_input_tokens": 100_000} + }, + "model": {"id": "opus"}, + } + result = hud_cache_savings.format_cache_savings(stdin) + # Should look like "💰$1.35 saved" + import re + + assert re.search(r"\$\d+\.\d{2} saved", result) + + +def test_format_negative_tokens_returns_empty(): + """Malformed payload with negative cache_read is silently skipped.""" + stdin = { + "context_window": { + "current_usage": {"cache_read_input_tokens": -500} + }, + "model": {"id": "opus"}, + } + assert hud_cache_savings.format_cache_savings(stdin) == "" diff --git a/packages/claude-code-plugin/tests/test_hud_context_bar.py b/packages/claude-code-plugin/tests/test_hud_context_bar.py new file mode 100644 index 00000000..d195e43c --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_context_bar.py @@ -0,0 +1,213 @@ +"""Behavior tests for hud_context_bar (Wave 2-E / #1326).""" +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +import hud_context_bar # noqa: E402 + +_FULL = "\u2588" +_EMPTY = "\u2591" +_DARK = "\u2593" +_WARN = "\u26a0" + + +# --------------------------- render_context_bar: basic shape -------------- + + +def test_zero_percent_all_empty(): + result = hud_context_bar.render_context_bar(0) + assert _FULL not in result + assert _EMPTY * hud_context_bar.CONTEXT_BAR_WIDTH in result + assert "0%" in result + + +def test_fifty_percent_half_filled(): + result = hud_context_bar.render_context_bar(50) + # 50% of 10 cells = 5 filled + assert _FULL * 5 in result + assert _EMPTY * 5 in result + assert "50%" in result + + +def test_hundred_percent_all_filled(): + """100% shows the full bar but also danger glyph + warning suffix.""" + result = hud_context_bar.render_context_bar(100) + # Danger glyph replaces last cell + assert _DARK in result + # And warning suffix + assert _WARN in result + assert "100%" in result + + +# --------------------------- shape: format -------------------------------- + + +def test_output_has_bracket_wrapper(): + result = hud_context_bar.render_context_bar(42) + assert result.startswith("[") + assert "] " in result + + +def test_output_shows_percent_symbol(): + result = hud_context_bar.render_context_bar(42) + assert "42%" in result + + +# --------------------------- rounding ------------------------------------- + + +def test_rounding_nearest_5pct_fills_half_cell(): + """5% rounds up to 1 filled cell (round-half-to-even).""" + result = hud_context_bar.render_context_bar(5) + # filled = round(5/100 * 10) = round(0.5) = 0 (banker's rounding) + # So 0 full cells expected. + assert _FULL not in result + + +def test_rounding_15pct_fills_2_cells(): + """15% → 1.5 → rounds to 2 (banker's rounding to even).""" + result = hud_context_bar.render_context_bar(15) + assert _FULL * 2 in result + + +def test_rounding_95pct_fills_10_with_danger(): + result = hud_context_bar.render_context_bar(95) + # filled = round(9.5) = 10 (banker's) → full bar with danger glyph + assert result.count(_FULL) + result.count(_DARK) == 10 + assert _DARK in result + assert _WARN in result + + +# --------------------------- warning / danger thresholds ------------------ + + +def test_below_warning_no_suffix(): + result = hud_context_bar.render_context_bar(50) + assert _WARN not in result + + +def test_at_warning_threshold_adds_suffix(): + """80% is the warning threshold (inclusive).""" + result = hud_context_bar.render_context_bar(80) + assert _WARN in result + + +def test_above_warning_has_suffix(): + result = hud_context_bar.render_context_bar(85) + assert _WARN in result + + +def test_below_danger_no_dark_glyph(): + result = hud_context_bar.render_context_bar(80) + # 80 is warning but below danger (85) → no dark glyph + assert _DARK not in result + + +def test_at_danger_threshold_has_dark_glyph(): + """85% is the danger threshold (inclusive).""" + result = hud_context_bar.render_context_bar(85) + assert _DARK in result + + +def test_above_danger_has_dark_glyph(): + result = hud_context_bar.render_context_bar(92) + assert _DARK in result + + +# --------------------------- clamping ------------------------------------- + + +def test_negative_clamped_to_zero(): + result = hud_context_bar.render_context_bar(-50) + assert "0%" in result + assert _FULL not in result + + +def test_above_100_clamped_to_100(): + result = hud_context_bar.render_context_bar(150) + assert "100%" in result + + +def test_non_numeric_treated_as_zero(): + result = hud_context_bar.render_context_bar("abc") + assert "0%" in result + + +def test_none_treated_as_zero(): + result = hud_context_bar.render_context_bar(None) + assert "0%" in result + + +def test_numeric_string_accepted(): + result = hud_context_bar.render_context_bar("42") + assert "42%" in result + + +# --------------------------- custom width --------------------------------- + + +def test_custom_width_20(): + result = hud_context_bar.render_context_bar(50, width=20) + # 50% of 20 = 10 filled + assert _FULL * 10 in result + + +def test_width_zero_returns_empty(): + assert hud_context_bar.render_context_bar(50, width=0) == "" + + +def test_width_one_minimal_bar(): + """Width 1 is a degenerate but valid case.""" + result = hud_context_bar.render_context_bar(100, width=1) + assert "[" in result + assert "]" in result + assert "100%" in result + + +# --------------------------- format_context_bar_segment ------------------- + + +def test_segment_empty_stdin(): + assert hud_context_bar.format_context_bar_segment({}) == "" + + +def test_segment_no_context_window(): + assert hud_context_bar.format_context_bar_segment({"cost": {}}) == "" + + +def test_segment_missing_used_percentage(): + stdin = {"context_window": {"total_tokens": 1000}} + assert hud_context_bar.format_context_bar_segment(stdin) == "" + + +def test_segment_normal_render(): + stdin = {"context_window": {"used_percentage": 42}} + result = hud_context_bar.format_context_bar_segment(stdin) + assert "42%" in result + assert "[" in result + + +def test_segment_zero_renders(): + """Zero percent still renders (not same as missing).""" + stdin = {"context_window": {"used_percentage": 0}} + result = hud_context_bar.format_context_bar_segment(stdin) + assert "0%" in result + + +# --------------------------- constants ------------------------------------ + + +def test_context_bar_width_default(): + assert hud_context_bar.CONTEXT_BAR_WIDTH == 10 + + +def test_thresholds_ordered(): + """warning ≤ danger ≤ critical.""" + w, d, c = hud_context_bar.CONTEXT_BAR_THRESHOLDS + assert w <= d <= c diff --git a/packages/claude-code-plugin/tests/test_hud_layout.py b/packages/claude-code-plugin/tests/test_hud_layout.py new file mode 100644 index 00000000..31da3671 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_layout.py @@ -0,0 +1,292 @@ +"""Behavior tests for hud_layout adaptive rendering (Wave 1-D / #1326).""" +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +import hud_layout # noqa: E402 + + +# --------------------------- visible_len ------------------------------------ + + +def test_visible_len_ascii(): + assert hud_layout.visible_len("hello") == 5 + + +def test_visible_len_empty(): + assert hud_layout.visible_len("") == 0 + + +def test_visible_len_cjk_doubled(): + """CJK characters count as 2 columns each.""" + assert hud_layout.visible_len("안녕") == 4 + assert hud_layout.visible_len("日本") == 4 + + +def test_visible_len_emoji_doubled(): + """Wide emoji count as 2 columns.""" + # U+1F7E2 green circle is East-Asian Wide + assert hud_layout.visible_len("A\U0001f7e2B") == 4 + + +def test_visible_len_mixed(): + assert hud_layout.visible_len("ab안c") == 5 # 1 + 1 + 2 + 1 + + +def test_visible_len_ansi_not_stripped(): + """ANSI escape characters are counted (caller must strip).""" + # This documents current behavior — Wave 2-D will revisit. + s = "\x1b[32mX\x1b[0m" + assert hud_layout.visible_len(s) > 1 + + +# --------------------------- terminal_width -------------------------------- + + +def test_terminal_width_falls_back_on_zero(monkeypatch): + """terminal_width returns fallback when shutil reports 0 columns.""" + import shutil as _shutil + + class FakeSize: + columns = 0 + lines = 24 + + monkeypatch.setattr( + _shutil, "get_terminal_size", lambda *a, **k: FakeSize() + ) + assert hud_layout.terminal_width() == hud_layout.FALLBACK_TERMINAL_WIDTH + + +def test_terminal_width_custom_fallback(monkeypatch): + """Fallback parameter is honoured when shutil raises.""" + import shutil as _shutil + + def _raise(*a, **k): + raise RuntimeError("no tty") + + monkeypatch.setattr(_shutil, "get_terminal_size", _raise) + assert hud_layout.terminal_width(fallback=60) == 60 + + +def test_terminal_width_uses_real_value(monkeypatch): + """When shutil reports a real value, terminal_width passes it through.""" + import shutil as _shutil + + class FakeSize: + columns = 100 + lines = 30 + + monkeypatch.setattr( + _shutil, "get_terminal_size", lambda *a, **k: FakeSize() + ) + assert hud_layout.terminal_width() == 100 + + +# --------------------------- shorten_model_label --------------------------- + + +def test_shorten_strips_context_suffix(): + assert ( + hud_layout.shorten_model_label("Opus 4.6 (1M context)") == "Opus 4.6" + ) + + +def test_shorten_keeps_plain_name(): + assert hud_layout.shorten_model_label("Sonnet 4.5") == "Sonnet 4.5" + + +def test_shorten_compact_mode(): + assert ( + hud_layout.shorten_model_label("Opus 4.6 (1M context)", compact=True) + == "Opus(1M)" + ) + + +def test_shorten_compact_fallback_no_context(): + """No context suffix → first token only in compact mode.""" + assert hud_layout.shorten_model_label("Sonnet 4.5", compact=True) == "Sonnet" + + +def test_shorten_compact_single_word(): + assert hud_layout.shorten_model_label("Haiku", compact=True) == "Haiku" + + +def test_shorten_empty_input(): + assert hud_layout.shorten_model_label("") == "" + + +def test_shorten_empty_compact(): + assert hud_layout.shorten_model_label("", compact=True) == "" + + +def test_shorten_case_insensitive_context_suffix(): + """Case differences in 'Context' still stripped.""" + assert ( + hud_layout.shorten_model_label("Opus 4.6 (1M Context)") == "Opus 4.6" + ) + + +# --------------------------- fit_segments ---------------------------------- + + +def test_fit_all_segments_fit_no_drop(): + segments = [ + ("face", 0, "◕‿◕ CB"), + ("mode", 1, "PLAN"), + ("cost", 2, "$1.23"), + ] + result = hud_layout.fit_segments(segments, width=60) + assert "◕‿◕ CB" in result + assert "PLAN" in result + assert "$1.23" in result + assert "|" in result # default separator contains | + + +def test_fit_drops_low_priority_when_tight(): + """Lowest-priority segment dropped first when width is exceeded.""" + segments = [ + ("face", 0, "FACE"), + ("mode", 1, "MODE"), + ("cost", 2, "COST-LONG"), + ] + # 15-col budget fits "FACE | MODE" (11) but not "FACE | MODE | COST-LONG" (23) + result = hud_layout.fit_segments(segments, width=15) + assert "FACE" in result + assert "MODE" in result + assert "COST-LONG" not in result + + +def test_fit_sacred_segments_never_dropped(): + """Priority 0 and 1 segments survive even when their neighbor is huge.""" + segments = [ + ("face", 0, "FACE"), + ("mode", 1, "MODE"), + ("x", 2, "X" * 100), + ] + result = hud_layout.fit_segments(segments, width=12) + assert "FACE" in result + assert "MODE" in result + + +def test_fit_empty_segments_skipped(): + segments = [ + ("face", 0, "FACE"), + ("mode", 1, ""), + ("cost", 2, "$1"), + ] + result = hud_layout.fit_segments(segments, width=40) + assert "FACE" in result + assert "$1" in result + # No double separator where the empty segment used to be + assert "||" not in result + assert " | | " not in result + + +def test_fit_hard_truncate_when_sacred_overflows(): + """When even sacred segments exceed the budget, hard-truncate with ellipsis.""" + segments = [ + ("face", 0, "A" * 50), + ("mode", 1, "B" * 50), + ] + result = hud_layout.fit_segments(segments, width=20) + assert result.endswith("\u2026") + assert hud_layout.visible_len(result) <= 20 + + +def test_fit_preserves_segment_order(): + segments = [ + ("face", 0, "FACE"), + ("mode", 1, "MODE"), + ] + result = hud_layout.fit_segments(segments, width=100) + assert result.index("FACE") < result.index("MODE") + + +def test_fit_drops_highest_priority_number_first(): + """When multiple segments are droppable, priority 8 goes before 3.""" + segments = [ + ("face", 0, "FACE"), + ("mode", 1, "MODE"), + ("duration", 3, "DUR"), + ("worktree", 8, "WT"), + ] + # Tight budget that keeps sacred+duration but not worktree + result = hud_layout.fit_segments(segments, width=20) + assert "FACE" in result + assert "MODE" in result + assert "WT" not in result + + +def test_fit_custom_separator(): + segments = [ + ("a", 0, "A"), + ("b", 1, "B"), + ] + assert hud_layout.fit_segments(segments, width=40, separator=" · ") == "A · B" + + +def test_fit_width_of_zero_yields_empty(): + segments = [("face", 0, "FACE")] + assert hud_layout.fit_segments(segments, width=0) == "" + + +def test_fit_width_of_one_returns_only_ellipsis(): + segments = [("face", 0, "LARGE")] + assert hud_layout.fit_segments(segments, width=1) == "\u2026" + + +def test_fit_ignores_empty_segment_list(): + assert hud_layout.fit_segments([], width=80) == "" + + +def test_fit_single_sacred_within_budget(): + segments = [("face", 0, "◕‿◕")] + assert hud_layout.fit_segments(segments, width=10) == "◕‿◕" + + +# --------------------------- SEGMENT_PRIORITY ------------------------------ + + +def test_segment_priority_has_sacred_entries(): + """SEGMENT_PRIORITY must include face_version and mode_health as sacred.""" + p = dict(hud_layout.SEGMENT_PRIORITY) + assert p["face_version"] == 0 + assert p["mode_health"] == 1 + + +def test_segment_priority_is_non_decreasing(): + """Priorities should be non-decreasing in the canonical list.""" + priorities = [p for _, p in hud_layout.SEGMENT_PRIORITY] + assert priorities == sorted(priorities) + + +def test_segment_priority_contains_all_statusline_slots(): + """All rendering slots documented in format_status_line are listed.""" + names = {name for name, _ in hud_layout.SEGMENT_PRIORITY} + expected = { + "face_version", + "mode_health", + "cost", + "duration", + "ctx", + "cache", + "model", + "rate_limits", + "worktree", + } + assert expected <= names + + +def test_sacred_priority_constant(): + """Sacred threshold is documented as 1.""" + assert hud_layout.SACRED_PRIORITY == 1 + + +def test_default_separator_is_pipe(): + assert hud_layout.DEFAULT_SEPARATOR == " | " diff --git a/packages/claude-code-plugin/tests/test_hud_rainbow.py b/packages/claude-code-plugin/tests/test_hud_rainbow.py new file mode 100644 index 00000000..07b55981 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_rainbow.py @@ -0,0 +1,229 @@ +"""Behavior tests for hud_rainbow ANSI coloring (Wave 2-D / #1326).""" +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +import hud_rainbow # noqa: E402 + + +# --------------------------- is_color_enabled ------------------------------ + + +def test_color_enabled_when_no_env_var(): + assert hud_rainbow.is_color_enabled(env={}) is True + + +def test_color_disabled_by_no_color_env(): + """NO_COLOR=1 disables color output.""" + assert hud_rainbow.is_color_enabled(env={"NO_COLOR": "1"}) is False + + +def test_color_disabled_by_any_nonempty_no_color(): + """Any non-empty NO_COLOR value disables color (per spec).""" + assert hud_rainbow.is_color_enabled(env={"NO_COLOR": "true"}) is False + assert hud_rainbow.is_color_enabled(env={"NO_COLOR": "yes"}) is False + + +def test_color_enabled_when_no_color_is_empty_string(): + """Empty NO_COLOR value means color is allowed.""" + assert hud_rainbow.is_color_enabled(env={"NO_COLOR": ""}) is True + + +def test_color_env_default_uses_os_environ(monkeypatch): + """When env=None, reads from os.environ.""" + monkeypatch.delenv("NO_COLOR", raising=False) + assert hud_rainbow.is_color_enabled() is True + monkeypatch.setenv("NO_COLOR", "1") + assert hud_rainbow.is_color_enabled() is False + + +# --------------------------- mode_glyph ----------------------------------- + + +def test_mode_glyph_plan(): + assert hud_rainbow.mode_glyph("PLAN") == "\u25c7" # ◇ + + +def test_mode_glyph_act(): + assert hud_rainbow.mode_glyph("ACT") == "\u25c6" # ◆ + + +def test_mode_glyph_eval(): + assert hud_rainbow.mode_glyph("EVAL") == "\u25c8" # ◈ + + +def test_mode_glyph_auto(): + assert hud_rainbow.mode_glyph("AUTO") == "\u25ca" # ◊ + + +def test_mode_glyph_case_insensitive(): + assert hud_rainbow.mode_glyph("plan") == "\u25c7" + + +def test_mode_glyph_unknown_returns_empty(): + assert hud_rainbow.mode_glyph("Ready") == "" + assert hud_rainbow.mode_glyph("DEBUG") == "" + + +def test_mode_glyph_empty_input(): + assert hud_rainbow.mode_glyph("") == "" + + +# --------------------------- gradient_ansi -------------------------------- + + +def test_gradient_single_color_wraps_text(): + """Single color palette wraps entire text in one escape.""" + result = hud_rainbow.gradient_ansi("PLAN", [(0, 0, 255)]) + assert "PLAN" in result + assert result.startswith("\x1b[38;2;0;0;255m") + assert result.endswith("\x1b[0m") + + +def test_gradient_multiple_colors_per_char(): + """Multi-stop palette assigns a color per character.""" + palette = [(255, 0, 0), (0, 255, 0), (0, 0, 255)] + result = hud_rainbow.gradient_ansi("abc", palette) + assert "a" in result + assert "b" in result + assert "c" in result + # At least one red and one blue escape present + assert "38;2;255;0;0" in result + assert "38;2;0;0;255" in result + + +def test_gradient_empty_text_returns_empty(): + assert hud_rainbow.gradient_ansi("", [(255, 0, 0)]) == "" + + +def test_gradient_empty_palette_returns_text_unchanged(): + assert hud_rainbow.gradient_ansi("PLAN", []) == "PLAN" + + +def test_gradient_ends_with_reset(): + result = hud_rainbow.gradient_ansi("X", [(100, 100, 100)]) + assert result.endswith(hud_rainbow.RESET) + + +# --------------------------- render_mode_rainbow -------------------------- + + +def test_render_plan_has_glyph_and_label(): + result = hud_rainbow.render_mode_rainbow("PLAN", enabled=False) + assert "\u25c7" in result # ◇ + assert "PLAN" in result + + +def test_render_act_has_glyph_and_label(): + result = hud_rainbow.render_mode_rainbow("ACT", enabled=False) + assert "\u25c6" in result # ◆ + assert "ACT" in result + + +def test_render_eval_has_glyph_and_label(): + result = hud_rainbow.render_mode_rainbow("EVAL", enabled=False) + assert "\u25c8" in result # ◈ + assert "EVAL" in result + + +def test_render_auto_has_glyph_and_label(): + result = hud_rainbow.render_mode_rainbow("AUTO", enabled=False) + assert "\u25ca" in result # ◊ + assert "AUTO" in result + + +def test_render_disabled_strips_ansi(): + """With enabled=False, output is plain text.""" + result = hud_rainbow.render_mode_rainbow("PLAN", enabled=False) + assert "\x1b[" not in result + assert result == "\u25c7 PLAN" + + +def test_render_enabled_wraps_ansi(): + """With enabled=True, output contains ANSI escape codes.""" + result = hud_rainbow.render_mode_rainbow("PLAN", enabled=True) + assert "\x1b[" in result + assert "\u25c7" in result + assert "PLAN" in result + assert result.endswith("\x1b[0m") + + +def test_render_auto_uses_multi_color_gradient(): + """AUTO mode applies a multi-stop rainbow.""" + result = hud_rainbow.render_mode_rainbow("AUTO", enabled=True) + # Rainbow palette has 6 distinct color escapes + assert result.count("\x1b[38;2;") >= 2 + + +def test_render_no_color_env_forces_plain(monkeypatch): + """NO_COLOR env var forces plain output.""" + monkeypatch.setenv("NO_COLOR", "1") + result = hud_rainbow.render_mode_rainbow("PLAN") + assert "\x1b[" not in result + + +def test_render_unknown_mode_plain(): + """Unknown mode still renders as plain uppercase text.""" + result = hud_rainbow.render_mode_rainbow("Ready", enabled=True) + assert "READY" in result + # No ANSI escapes because no palette entry for unknown mode + assert "\x1b[" not in result + + +def test_render_empty_mode_returns_empty(): + assert hud_rainbow.render_mode_rainbow("") == "" + + +def test_render_case_insensitive(): + """Mode name matching is case-insensitive.""" + result = hud_rainbow.render_mode_rainbow("plan", enabled=False) + assert "PLAN" in result + assert "\u25c7" in result + + +# --------------------------- strip_ansi ----------------------------------- + + +def test_strip_ansi_removes_escapes(): + colored = hud_rainbow.render_mode_rainbow("PLAN", enabled=True) + stripped = hud_rainbow.strip_ansi(colored) + assert stripped == "\u25c7 PLAN" + + +def test_strip_ansi_noop_on_plain_text(): + assert hud_rainbow.strip_ansi("hello") == "hello" + + +def test_strip_ansi_empty_string(): + assert hud_rainbow.strip_ansi("") == "" + + +def test_strip_ansi_preserves_text_between_escapes(): + s = "\x1b[31mred\x1b[0m and \x1b[32mgreen\x1b[0m" + assert hud_rainbow.strip_ansi(s) == "red and green" + + +# --------------------------- MODE_PALETTE --------------------------------- + + +def test_mode_palette_has_all_four_modes(): + for mode in ("PLAN", "ACT", "EVAL", "AUTO"): + assert mode in hud_rainbow.MODE_PALETTE + assert len(hud_rainbow.MODE_PALETTE[mode]) >= 1 + + +def test_mode_palette_auto_is_rainbow(): + """AUTO palette is a multi-stop gradient.""" + assert len(hud_rainbow.MODE_PALETTE["AUTO"]) >= 3 + + +def test_mode_palette_solid_modes_have_single_stop(): + """PLAN, ACT, EVAL are solid colors (single stop).""" + for mode in ("PLAN", "ACT", "EVAL"): + assert len(hud_rainbow.MODE_PALETTE[mode]) == 1 diff --git a/packages/claude-code-plugin/tests/test_hud_rate_limits.py b/packages/claude-code-plugin/tests/test_hud_rate_limits.py new file mode 100644 index 00000000..07f4ba36 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_rate_limits.py @@ -0,0 +1,179 @@ +"""Behavior tests for hud_rate_limits severity rendering (Wave 1-C / #1326).""" +import importlib +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +import hud_rate_limits # noqa: E402 + +_ICON_LOW = "\u2591" # ░ +_ICON_MID = "\u2592" # ▒ +_ICON_HIGH = "\u2593" # ▓ + + +# --------------------------- fallthrough cases ------------------------------ + + +def test_empty_returns_empty(): + assert hud_rate_limits.format_rate_limits({}) == "" + + +def test_no_rate_limits_key_returns_empty(): + assert hud_rate_limits.format_rate_limits({"cost": {}}) == "" + + +def test_empty_rate_limits_object_returns_empty(): + assert hud_rate_limits.format_rate_limits({"rate_limits": {}}) == "" + + +# --------------------------- severity buckets ------------------------------ + + +def test_five_hour_low_severity(): + """23.5% → rounds to 24%, ≤ 60 → ░ low icon.""" + stdin = {"rate_limits": {"five_hour": {"used_percentage": 23.5}}} + assert ( + hud_rate_limits.format_rate_limits(stdin) + == f"RL:5h{_ICON_LOW}24%" + ) + + +def test_five_hour_medium_severity(): + """75% → within (60, 85] → ▒ medium icon.""" + stdin = {"rate_limits": {"five_hour": {"used_percentage": 75}}} + assert ( + hud_rate_limits.format_rate_limits(stdin) + == f"RL:5h{_ICON_MID}75%" + ) + + +def test_five_hour_high_severity(): + """96% → > 85 → ▓ high icon.""" + stdin = {"rate_limits": {"five_hour": {"used_percentage": 96}}} + assert ( + hud_rate_limits.format_rate_limits(stdin) + == f"RL:5h{_ICON_HIGH}96%" + ) + + +def test_boundary_60_is_low(): + """Exactly 60% stays in the low bucket (inclusive upper bound).""" + stdin = {"rate_limits": {"five_hour": {"used_percentage": 60}}} + assert _ICON_LOW in hud_rate_limits.format_rate_limits(stdin) + + +def test_boundary_85_is_medium(): + """Exactly 85% is still medium (inclusive upper bound).""" + stdin = {"rate_limits": {"five_hour": {"used_percentage": 85}}} + assert _ICON_MID in hud_rate_limits.format_rate_limits(stdin) + + +def test_boundary_over_85_is_high(): + """85.01% crosses into high.""" + stdin = {"rate_limits": {"five_hour": {"used_percentage": 85.01}}} + assert _ICON_HIGH in hud_rate_limits.format_rate_limits(stdin) + + +# --------------------------- seven-day tier -------------------------------- + + +def test_seven_day_only(): + """Seven-day tier renders with its own icon.""" + stdin = {"rate_limits": {"seven_day": {"used_percentage": 80}}} + result = hud_rate_limits.format_rate_limits(stdin) + assert f"7d{_ICON_MID}80%" in result + + +# --------------------------- both tiers, separator ------------------------- + + +def test_both_tiers_space_separated(): + stdin = { + "rate_limits": { + "five_hour": {"used_percentage": 10}, + "seven_day": {"used_percentage": 40}, + } + } + result = hud_rate_limits.format_rate_limits(stdin) + assert result == f"RL:5h{_ICON_LOW}10% 7d{_ICON_LOW}40%" + assert "," not in result # Wave 1-C replaces comma with space + + +def test_both_tiers_mixed_severity(): + """One tier low, one critical.""" + stdin = { + "rate_limits": { + "five_hour": {"used_percentage": 13}, + "seven_day": {"used_percentage": 96}, + } + } + result = hud_rate_limits.format_rate_limits(stdin) + assert f"5h{_ICON_LOW}13%" in result + assert f"7d{_ICON_HIGH}96%" in result + + +# --------------------------- defensive coercion ---------------------------- + + +def test_none_percentage_coerced_to_zero(): + """None used_percentage does not crash; renders as 0%.""" + stdin = {"rate_limits": {"five_hour": {"used_percentage": None}}} + result = hud_rate_limits.format_rate_limits(stdin) + assert result == f"RL:5h{_ICON_LOW}0%" + + +def test_string_percentage_coerced_to_zero(): + """Non-numeric string used_percentage does not crash.""" + stdin = {"rate_limits": {"five_hour": {"used_percentage": "N/A"}}} + result = hud_rate_limits.format_rate_limits(stdin) + assert result == f"RL:5h{_ICON_LOW}0%" + + +def test_numeric_string_percentage_accepted(): + """Numeric string is parsed as float.""" + stdin = {"rate_limits": {"five_hour": {"used_percentage": "42"}}} + result = hud_rate_limits.format_rate_limits(stdin) + assert f"5h{_ICON_LOW}42%" in result + + +def test_empty_tier_object_is_skipped(): + """Empty tier dict (falsy) is skipped, not rendered as 0%. + + This mirrors the original Wave 0 behavior: `if five:` checks + truthiness, and an empty dict falls through without being rendered. + """ + stdin = {"rate_limits": {"five_hour": {}}} + result = hud_rate_limits.format_rate_limits(stdin) + assert result == "" + + +def test_tier_with_null_used_percentage_renders_zero(): + """Explicit None used_percentage (key present) coerces to 0%.""" + stdin = {"rate_limits": {"five_hour": {"used_percentage": None, "foo": "bar"}}} + result = hud_rate_limits.format_rate_limits(stdin) + assert result == f"RL:5h{_ICON_LOW}0%" + + +# --------------------------- re-export lock -------------------------------- + + +def test_reexport_identity_from_codingbuddy_hud(): + """Lock: codingbuddy-hud.format_rate_limits must be the same object.""" + hud_main = importlib.import_module("codingbuddy-hud") + assert hud_main.format_rate_limits is hud_rate_limits.format_rate_limits + + +# --------------------------- internal helper exposure ---------------------- + + +def test_severity_icon_helper_exposed(): + """_severity_icon is a documented internal helper.""" + assert hud_rate_limits._severity_icon(10) == _ICON_LOW + assert hud_rate_limits._severity_icon(70) == _ICON_MID + assert hud_rate_limits._severity_icon(90) == _ICON_HIGH diff --git a/packages/claude-code-plugin/tests/test_hud_session.py b/packages/claude-code-plugin/tests/test_hud_session.py new file mode 100644 index 00000000..9a979597 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_session.py @@ -0,0 +1,273 @@ +"""Behavior tests for hud_session self-heal (Wave 1-B / #1326).""" +import os +import sys +from datetime import datetime, timedelta, timezone + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +import hud_session # noqa: E402 + + +# --------------------------- detect_stale_session --------------------------- + + +def test_empty_state_not_stale(): + """Empty state is not stale — there is nothing to heal.""" + assert hud_session.detect_stale_session({}) is False + + +def test_none_state_not_stale(): + """None-ish state is not stale.""" + assert hud_session.detect_stale_session(None) is False # type: ignore[arg-type] + + +def test_manual_fix_marker_is_stale(): + """'manual-fix' is a known repair marker.""" + assert hud_session.detect_stale_session({"sessionId": "manual-fix"}) is True + + +def test_empty_session_id_is_stale(): + """Empty sessionId indicates uninitialized state.""" + assert hud_session.detect_stale_session({"sessionId": ""}) is True + + +def test_unknown_marker_is_stale(): + """'unknown' is treated as a repair marker.""" + assert hud_session.detect_stale_session({"sessionId": "unknown"}) is True + + +def test_none_marker_is_stale(): + """'none' string is treated as a repair marker.""" + assert hud_session.detect_stale_session({"sessionId": "none"}) is True + + +def test_stdin_mismatch_is_stale(): + """When stdin provides a different sessionId, state is stale.""" + now = datetime.now(timezone.utc).isoformat() + state = {"sessionId": "abc-123", "sessionStartTimestamp": now} + assert ( + hud_session.detect_stale_session(state, stdin_session_id="def-456") + is True + ) + + +def test_stdin_match_not_stale(): + """When stdin matches and timestamp is fresh, state is valid.""" + now = datetime.now(timezone.utc).isoformat() + state = {"sessionId": "abc-123", "sessionStartTimestamp": now} + assert ( + hud_session.detect_stale_session(state, stdin_session_id="abc-123") + is False + ) + + +def test_empty_stdin_session_id_skips_mismatch_check(): + """Empty stdin_session_id means 'not available' — skip that check.""" + now = datetime.now(timezone.utc).isoformat() + state = {"sessionId": "abc-123", "sessionStartTimestamp": now} + assert hud_session.detect_stale_session(state, stdin_session_id="") is False + + +def test_old_timestamp_is_stale(): + """Timestamp older than SESSION_STALE_SECONDS triggers stale.""" + old = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() + state = {"sessionId": "abc-123", "sessionStartTimestamp": old} + assert hud_session.detect_stale_session(state) is True + + +def test_recent_timestamp_not_stale(): + """Timestamp within SESSION_STALE_SECONDS is fresh.""" + recent = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() + state = {"sessionId": "abc-123", "sessionStartTimestamp": recent} + assert hud_session.detect_stale_session(state) is False + + +def test_unparseable_timestamp_is_stale(): + """Garbage timestamp is treated as stale.""" + state = { + "sessionId": "abc-123", + "sessionStartTimestamp": "not-a-timestamp", + } + assert hud_session.detect_stale_session(state) is True + + +def test_naive_timestamp_treated_as_utc(): + """Naive datetime string (no tz) is interpreted as UTC.""" + now = datetime.now(timezone.utc) + naive_str = now.replace(tzinfo=None).isoformat() + state = {"sessionId": "abc-123", "sessionStartTimestamp": naive_str} + assert hud_session.detect_stale_session(state, now=now) is False + + +def test_now_override_for_deterministic_age_check(): + """Clock override makes age checks deterministic.""" + fixed_now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc) + old = (fixed_now - timedelta(hours=5)).isoformat() + fresh = (fixed_now - timedelta(hours=1)).isoformat() + assert ( + hud_session.detect_stale_session( + {"sessionId": "abc", "sessionStartTimestamp": old}, now=fixed_now + ) + is True + ) + assert ( + hud_session.detect_stale_session( + {"sessionId": "abc", "sessionStartTimestamp": fresh}, now=fixed_now + ) + is False + ) + + +def test_repair_marker_beats_fresh_timestamp(): + """Repair marker triggers stale even when timestamp is fresh.""" + now = datetime.now(timezone.utc).isoformat() + state = {"sessionId": "manual-fix", "sessionStartTimestamp": now} + assert hud_session.detect_stale_session(state) is True + + +# --------------------------- heal_stale_state ------------------------------- + + +def test_heal_clears_ephemeral_fields(): + """heal_stale_state zeroes out rendering-relevant fields.""" + state = { + "sessionId": "abc-123", + "sessionStartTimestamp": "2026-04-01T00:00:00+00:00", + "currentMode": "ACT", + "version": "5.2.0", + "activeAgent": "code-reviewer", + "phase": "executing", + "focus": "debugging", + "blockerCount": 3, + } + healed = hud_session.heal_stale_state(state) + assert healed["currentMode"] is None + assert healed["version"] == "" + assert healed["activeAgent"] is None + assert healed["phase"] == "ready" + assert healed["focus"] is None + assert healed["blockerCount"] == 0 + + +def test_heal_preserves_session_id_and_timestamp(): + """heal_stale_state keeps sessionId and sessionStartTimestamp intact.""" + state = { + "sessionId": "abc-123", + "sessionStartTimestamp": "2026-04-01T00:00:00+00:00", + "currentMode": "ACT", + } + healed = hud_session.heal_stale_state(state) + assert healed["sessionId"] == "abc-123" + assert healed["sessionStartTimestamp"] == "2026-04-01T00:00:00+00:00" + + +def test_heal_does_not_mutate_input(): + """heal_stale_state returns a copy, not a mutated input.""" + state = { + "sessionId": "abc-123", + "currentMode": "ACT", + "version": "5.2.0", + } + healed = hud_session.heal_stale_state(state) + assert state["currentMode"] == "ACT" # original untouched + assert state["version"] == "5.2.0" + assert healed is not state + + +def test_heal_preserves_unknown_fields(): + """Fields not in the clear-set are preserved.""" + state = { + "sessionId": "abc-123", + "currentMode": "ACT", + "customField": "should survive", + "anotherCustom": 42, + } + healed = hud_session.heal_stale_state(state) + assert healed["customField"] == "should survive" + assert healed["anotherCustom"] == 42 + + +def test_heal_empty_state(): + """Healing an empty dict produces a dict with default clears.""" + healed = hud_session.heal_stale_state({}) + assert healed["currentMode"] is None + assert healed["version"] == "" + assert healed["phase"] == "ready" + + +# --------------------------- reset_stale_session ---------------------------- + + +def test_reset_stale_session_noop_on_fresh_state(tmp_path): + """reset_stale_session should not modify a fresh state file.""" + import json + + state_file = tmp_path / "hud-state.json" + fresh = { + "sessionId": "abc-123", + "sessionStartTimestamp": datetime.now(timezone.utc).isoformat(), + "currentMode": "ACT", + "version": "5.5.0", + } + state_file.write_text(json.dumps(fresh), encoding="utf-8") + + hud_session.reset_stale_session(str(state_file)) + + after = json.loads(state_file.read_text(encoding="utf-8")) + assert after["currentMode"] == "ACT" # unchanged + assert after["version"] == "5.5.0" + + +def test_reset_stale_session_heals_repair_marker(tmp_path): + """reset_stale_session writes healed fields to a marker-tainted file.""" + import json + + state_file = tmp_path / "hud-state.json" + stale = { + "sessionId": "manual-fix", + "sessionStartTimestamp": datetime.now(timezone.utc).isoformat(), + "currentMode": "ACT", + "version": "5.2.0", + "activeAgent": "old-agent", + "phase": "executing", + "focus": "old-focus", + "blockerCount": 5, + } + state_file.write_text(json.dumps(stale), encoding="utf-8") + + hud_session.reset_stale_session(str(state_file)) + + after = json.loads(state_file.read_text(encoding="utf-8")) + assert after["currentMode"] is None + assert after["version"] == "" + assert after["activeAgent"] is None + assert after["phase"] == "ready" + assert after["focus"] is None + assert after["blockerCount"] == 0 + + +def test_reset_stale_session_silent_on_missing_file(tmp_path): + """reset_stale_session never raises when the file is missing.""" + missing = tmp_path / "absent.json" + # Should not raise + hud_session.reset_stale_session(str(missing)) + + +def test_reset_stale_session_silent_on_malformed_file(tmp_path): + """reset_stale_session never raises when the file is not JSON.""" + bad = tmp_path / "hud-state.json" + bad.write_text("not a json at all", encoding="utf-8") + hud_session.reset_stale_session(str(bad)) + + +# --------------------------- constants -------------------------------------- + + +def test_session_stale_seconds_is_four_hours(): + """Document the stale threshold constant.""" + assert hud_session.SESSION_STALE_SECONDS == 4 * 60 * 60 diff --git a/packages/claude-code-plugin/tests/test_hud_velocity.py b/packages/claude-code-plugin/tests/test_hud_velocity.py new file mode 100644 index 00000000..729e14c9 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_velocity.py @@ -0,0 +1,215 @@ +"""Behavior tests for hud_velocity cost spend rate (Wave 2-B / #1326).""" +import os +import sys +from datetime import datetime, timedelta, timezone + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +import hud_velocity # noqa: E402 + +_HOT = "\U0001f525" +_RISING = "\u2197" +_STEADY = "\u2192" +_IDLE = "\U0001f4a4" + + +# --------------------------- compute_spend_rate --------------------------- + + +def test_compute_zero_cost_returns_zero(): + assert hud_velocity.compute_spend_rate(0, 60_000) == 0.0 + + +def test_compute_zero_duration_returns_zero(): + assert hud_velocity.compute_spend_rate(1.0, 0) == 0.0 + + +def test_compute_negative_cost_returns_zero(): + assert hud_velocity.compute_spend_rate(-1, 60_000) == 0.0 + + +def test_compute_negative_duration_returns_zero(): + assert hud_velocity.compute_spend_rate(1.0, -60_000) == 0.0 + + +def test_compute_non_numeric_cost_returns_zero(): + assert hud_velocity.compute_spend_rate("abc", 60_000) == 0.0 + + +def test_compute_non_numeric_duration_returns_zero(): + assert hud_velocity.compute_spend_rate(1.0, "abc") == 0.0 + + +def test_compute_1_dollar_1_minute(): + """$1 over 1 minute → $1/min.""" + assert hud_velocity.compute_spend_rate(1.0, 60_000) == 1.0 + + +def test_compute_half_dollar_30_seconds(): + """$0.50 over 30s → $1/min.""" + assert hud_velocity.compute_spend_rate(0.50, 30_000) == 1.0 + + +def test_compute_ten_minute_session(): + """$6 over 10 minutes → $0.60/min.""" + rate = hud_velocity.compute_spend_rate(6.0, 10 * 60_000) + assert abs(rate - 0.60) < 0.001 + + +def test_compute_numeric_strings_accepted(): + assert hud_velocity.compute_spend_rate("1.0", "60000") == 1.0 + + +# --------------------------- trend_glyph --------------------------------- + + +def test_trend_glyph_hot(): + """Rate >= $0.20/min → 🔥.""" + assert hud_velocity.trend_glyph(0.25) == _HOT + assert hud_velocity.trend_glyph(1.0) == _HOT + + +def test_trend_glyph_rising(): + """$0.01 ≤ rate < $0.20 → ↗.""" + assert hud_velocity.trend_glyph(0.05) == _RISING + assert hud_velocity.trend_glyph(0.15) == _RISING + + +def test_trend_glyph_steady(): + """Positive but below idle-max → →.""" + assert hud_velocity.trend_glyph(0.005) == _STEADY + + +def test_trend_glyph_idle_zero(): + """Zero rate → 💤.""" + assert hud_velocity.trend_glyph(0.0) == _IDLE + + +def test_trend_glyph_idle_negative(): + """Negative rate (nonsense) → 💤.""" + assert hud_velocity.trend_glyph(-0.5) == _IDLE + + +def test_trend_glyph_non_numeric(): + assert hud_velocity.trend_glyph("abc") == _IDLE # type: ignore[arg-type] + + +def test_trend_glyph_threshold_boundaries(): + """Exact threshold values belong to the upper tier (inclusive).""" + assert hud_velocity.trend_glyph(hud_velocity.TREND_HOT_MIN) == _HOT + assert hud_velocity.trend_glyph(hud_velocity.TREND_IDLE_MAX) == _RISING + + +# --------------------------- format_velocity_segment --------------------- + + +def test_format_segment_empty_stdin_returns_empty(): + assert hud_velocity.format_velocity_segment({}) == "" + + +def test_format_segment_missing_cost_returns_empty(): + assert hud_velocity.format_velocity_segment({"cost": {}}) == "" + + +def test_format_segment_missing_duration_without_state_returns_empty(): + """Without hud_state fallback, missing duration → empty.""" + stdin = {"cost": {"total_cost_usd": 1.0}} + assert hud_velocity.format_velocity_segment(stdin) == "" + + +def test_format_segment_zero_rate_returns_empty(): + stdin = {"cost": {"total_cost_usd": 0.0, "total_duration_ms": 60_000}} + assert hud_velocity.format_velocity_segment(stdin) == "" + + +def test_format_segment_normal_rendering(): + """$1.00 over 60s → rate $1/min, hot burn tier.""" + stdin = {"cost": {"total_cost_usd": 1.0, "total_duration_ms": 60_000}} + result = hud_velocity.format_velocity_segment(stdin) + assert _HOT in result + assert "$1.00/m" in result + + +def test_format_segment_rising_tier(): + """$0.05 over 60s → rate $0.05/min → rising.""" + stdin = {"cost": {"total_cost_usd": 0.05, "total_duration_ms": 60_000}} + result = hud_velocity.format_velocity_segment(stdin) + assert _RISING in result + assert "$0.05/m" in result + + +def test_format_segment_duration_fallback_from_state(): + """When stdin lacks duration, use hud_state.sessionStartTimestamp.""" + start = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat() + stdin = {"cost": {"total_cost_usd": 6.0}} + state = {"sessionStartTimestamp": start} + result = hud_velocity.format_velocity_segment(stdin, state) + assert result != "" + # Should be roughly $0.60/min + assert "$0.6" in result or "$0.5" in result # allow slight drift + + +def test_format_segment_duration_fallback_no_state(): + """No state → no fallback → empty.""" + stdin = {"cost": {"total_cost_usd": 6.0}} + assert hud_velocity.format_velocity_segment(stdin, None) == "" + + +# --------------------------- format_cost_with_velocity ------------------- + + +def test_cost_with_velocity_exact_prefix(): + stdin = {"cost": {"total_cost_usd": 1.23, "total_duration_ms": 60_000}} + result = hud_velocity.format_cost_with_velocity( + 1.23, stdin, is_exact=True + ) + assert result.startswith("$1.23") + + +def test_cost_with_velocity_estimate_prefix(): + stdin = {"cost": {"total_cost_usd": 1.23, "total_duration_ms": 60_000}} + result = hud_velocity.format_cost_with_velocity( + 1.23, stdin, is_exact=False + ) + assert result.startswith("~$1.23") + + +def test_cost_without_velocity_fallback(): + """Empty stdin → just the cost, no velocity suffix.""" + result = hud_velocity.format_cost_with_velocity(1.23, {}) + assert result == "$1.23" + + +def test_cost_non_numeric_coerced_to_zero(): + result = hud_velocity.format_cost_with_velocity("abc", {}) + assert result == "$0.00" + + +def test_cost_with_velocity_has_both_parts(): + stdin = {"cost": {"total_cost_usd": 1.23, "total_duration_ms": 60_000}} + result = hud_velocity.format_cost_with_velocity(1.23, stdin) + # Cost first, then velocity suffix + assert "$1.23" in result + assert "/m" in result + + +def test_cost_with_velocity_two_decimals(): + stdin = {"cost": {"total_cost_usd": 0.1234, "total_duration_ms": 60_000}} + result = hud_velocity.format_cost_with_velocity(0.1234, stdin) + assert "$0.12" in result + + +# --------------------------- constants ----------------------------------- + + +def test_hot_min_greater_than_idle_max(): + assert hud_velocity.TREND_HOT_MIN > hud_velocity.TREND_IDLE_MAX + + +def test_hot_min_is_positive(): + assert hud_velocity.TREND_HOT_MIN > 0 diff --git a/packages/claude-code-plugin/tests/test_hud_version.py b/packages/claude-code-plugin/tests/test_hud_version.py new file mode 100644 index 00000000..a8680103 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_version.py @@ -0,0 +1,168 @@ +"""Sanity + behavior test for the hud_version module (Wave 0 / #1463).""" +import importlib +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +import hud_version # noqa: E402 + + +def test_module_has_public_api(): + assert hasattr(hud_version, "get_fresh_version") + + +def test_fallback_to_hud_state_version(tmp_path): + missing = tmp_path / "no_plugins.json" + result = hud_version.get_fresh_version( + {"version": "9.9.9"}, plugins_file=str(missing) + ) + assert result == "9.9.9" + + +def test_empty_state_returns_empty(tmp_path): + missing = tmp_path / "no_plugins.json" + result = hud_version.get_fresh_version({}, plugins_file=str(missing)) + assert result == "" + + +def test_reads_installed_plugins_file_when_present(tmp_path): + """Contract: a real installed_plugins.json overrides hud_state.""" + plugins = tmp_path / "installed_plugins.json" + plugins.write_text( + '{"plugins": {"codingbuddy@v1": [{"version": "7.7.7"}]}}', + encoding="utf-8", + ) + result = hud_version.get_fresh_version( + {"version": "ignored"}, plugins_file=str(plugins) + ) + assert result == "7.7.7" + + +def test_plugin_json_fallback_when_installed_plugins_missing(tmp_path): + """Wave 1-A: plugin.json is the second-tier fallback. + + When installed_plugins.json is missing but a local plugin.json + exists, the returned version must be the plugin.json value. + """ + missing_plugins = tmp_path / "no_plugins.json" + plugin_json = tmp_path / "plugin.json" + plugin_json.write_text('{"version": "8.8.8"}', encoding="utf-8") + result = hud_version.get_fresh_version( + {"version": "stale"}, + plugins_file=str(missing_plugins), + plugin_json_file=str(plugin_json), + ) + assert result == "8.8.8" + + +def test_installed_plugins_wins_over_plugin_json(tmp_path): + """Wave 1-A: installed_plugins.json (tier 1) beats plugin.json (tier 2).""" + plugins = tmp_path / "installed_plugins.json" + plugins.write_text( + '{"plugins": {"codingbuddy@dev": [{"version": "tier-1"}]}}', + encoding="utf-8", + ) + plugin_json = tmp_path / "plugin.json" + plugin_json.write_text('{"version": "tier-2"}', encoding="utf-8") + result = hud_version.get_fresh_version( + {"version": "tier-3"}, + plugins_file=str(plugins), + plugin_json_file=str(plugin_json), + ) + assert result == "tier-1" + + +def test_plugin_json_beats_hud_state(tmp_path): + """Wave 1-A: plugin.json (tier 2) beats hud_state.version (tier 3).""" + missing_plugins = tmp_path / "no_plugins.json" + plugin_json = tmp_path / "plugin.json" + plugin_json.write_text('{"version": "tier-2"}', encoding="utf-8") + result = hud_version.get_fresh_version( + {"version": "tier-3-stale"}, + plugins_file=str(missing_plugins), + plugin_json_file=str(plugin_json), + ) + assert result == "tier-2" + + +def test_all_fallbacks_fail_returns_hud_state_version(tmp_path): + """Wave 1-A: if both files are absent, fall through to hud_state.""" + missing_plugins = tmp_path / "no_plugins.json" + missing_plugin_json = tmp_path / "no_plugin.json" + result = hud_version.get_fresh_version( + {"version": "9.9.9"}, + plugins_file=str(missing_plugins), + plugin_json_file=str(missing_plugin_json), + ) + assert result == "9.9.9" + + +def test_plugin_json_malformed_skipped(tmp_path): + """Wave 1-A: malformed plugin.json must not crash — skip to hud_state.""" + missing_plugins = tmp_path / "no_plugins.json" + bad_plugin_json = tmp_path / "plugin.json" + bad_plugin_json.write_text("this is not json", encoding="utf-8") + result = hud_version.get_fresh_version( + {"version": "fallback"}, + plugins_file=str(missing_plugins), + plugin_json_file=str(bad_plugin_json), + ) + assert result == "fallback" + + +def test_plugin_json_missing_version_key_skipped(tmp_path): + """Wave 1-A: plugin.json without version key skips to hud_state.""" + missing_plugins = tmp_path / "no_plugins.json" + plugin_json = tmp_path / "plugin.json" + plugin_json.write_text('{"name": "codingbuddy"}', encoding="utf-8") + result = hud_version.get_fresh_version( + {"version": "fallback"}, + plugins_file=str(missing_plugins), + plugin_json_file=str(plugin_json), + ) + assert result == "fallback" + + +def test_default_plugin_json_path_resolves_to_real_file(): + """Wave 1-A: __file__-relative default path must point at the real + .claude-plugin/plugin.json in the repo so dev installs work.""" + import pathlib + path = hud_version._default_plugin_json_path() + assert os.path.isfile(path), ( + f"Expected plugin.json at {path}; hud_version default path is wrong." + ) + # Smoke check: the file is parseable and has a version field + version = hud_version._read_local_plugin_json(path) + assert version, "plugin.json exists but version field is empty" + + +def test_import_does_not_read_real_plugins_file(monkeypatch, tmp_path): + """Lock: module load must not touch ~/.claude/plugins/installed_plugins.json.""" + fake_home = tmp_path / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + # Use reload (not sys.modules.pop + import_module) so the top-level + # `import hud_version` binding above stays live. Otherwise sibling + # tests asserting identity (e.g. test_reexport_alias_from_codingbuddy_hud) + # would break because they reference an obsolete module object. + importlib.reload(hud_version) + # Reaching this line means reload succeeded without touching real FS. + assert True + + +def test_reexport_alias_from_codingbuddy_hud(): + """Lock: hud._get_fresh_version must be hud_version.get_fresh_version. + + Uses reload to re-sync after any earlier test that mutated sys.modules + (test order should not matter for identity locks). + """ + importlib.reload(hud_version) + sys.modules.pop("codingbuddy-hud", None) + hud_main = importlib.import_module("codingbuddy-hud") + assert hud_main._get_fresh_version is hud_version.get_fresh_version diff --git a/yarn.lock b/yarn.lock index 1eac05b0..76937473 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1561,6 +1561,13 @@ __metadata: languageName: node linkType: hard +"@next/env@npm:16.2.3": + version: 16.2.3 + resolution: "@next/env@npm:16.2.3" + checksum: 10c0/56c3fee8ea226efe59ef065e054380f872c00c45c9fe4475eaa45f80773c3c1adc3ead3ccdd77447d3c1aeb4b3004aaaa033dd4a100d3e572fd01b83f992dde8 + languageName: node + linkType: hard + "@next/eslint-plugin-next@npm:16.1.6": version: 16.1.6 resolution: "@next/eslint-plugin-next@npm:16.1.6" @@ -1570,6 +1577,15 @@ __metadata: languageName: node linkType: hard +"@next/eslint-plugin-next@npm:16.2.3": + version: 16.2.3 + resolution: "@next/eslint-plugin-next@npm:16.2.3" + dependencies: + fast-glob: "npm:3.3.1" + checksum: 10c0/be881aa89e0840ab60455b07a2bb9ec0d686c664a0d91e8ca815797a65ca71d7bd79d186b0df5b6892c2bf57bd07fa05421cd93e2812dfeaedfad5ed9fd1023e + languageName: node + linkType: hard + "@next/swc-darwin-arm64@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-darwin-arm64@npm:16.1.6" @@ -1577,6 +1593,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-darwin-arm64@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-darwin-arm64@npm:16.2.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@next/swc-darwin-x64@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-darwin-x64@npm:16.1.6" @@ -1584,6 +1607,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-darwin-x64@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-darwin-x64@npm:16.2.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@next/swc-linux-arm64-gnu@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-linux-arm64-gnu@npm:16.1.6" @@ -1591,6 +1621,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-arm64-gnu@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-linux-arm64-gnu@npm:16.2.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@next/swc-linux-arm64-musl@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-linux-arm64-musl@npm:16.1.6" @@ -1598,6 +1635,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-arm64-musl@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-linux-arm64-musl@npm:16.2.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@next/swc-linux-x64-gnu@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-linux-x64-gnu@npm:16.1.6" @@ -1605,6 +1649,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-x64-gnu@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-linux-x64-gnu@npm:16.2.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@next/swc-linux-x64-musl@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-linux-x64-musl@npm:16.1.6" @@ -1612,6 +1663,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-x64-musl@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-linux-x64-musl@npm:16.2.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@next/swc-win32-arm64-msvc@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-win32-arm64-msvc@npm:16.1.6" @@ -1619,6 +1677,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-win32-arm64-msvc@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-win32-arm64-msvc@npm:16.2.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@next/swc-win32-x64-msvc@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-win32-x64-msvc@npm:16.1.6" @@ -1626,6 +1691,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-win32-x64-msvc@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-win32-x64-msvc@npm:16.2.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -5276,6 +5348,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.9.19": + version: 2.10.17 + resolution: "baseline-browser-mapping@npm:2.10.17" + bin: + baseline-browser-mapping: dist/cli.cjs + checksum: 10c0/e792a92a6b206521681e3ab3a72770023f74a3274450bfe11ba55a075ba26f5820d5d2d02d92e25224b8d01e327b78fbf3e116bdc6ac74b3d9c52f5e3f4a048a + languageName: node + linkType: hard + "better-sqlite3@npm:^11.9.1": version: 11.10.0 resolution: "better-sqlite3@npm:11.10.0" @@ -7028,6 +7109,29 @@ __metadata: languageName: node linkType: hard +"eslint-config-next@npm:16.2.3": + version: 16.2.3 + resolution: "eslint-config-next@npm:16.2.3" + dependencies: + "@next/eslint-plugin-next": "npm:16.2.3" + eslint-import-resolver-node: "npm:^0.3.6" + eslint-import-resolver-typescript: "npm:^3.5.2" + eslint-plugin-import: "npm:^2.32.0" + eslint-plugin-jsx-a11y: "npm:^6.10.0" + eslint-plugin-react: "npm:^7.37.0" + eslint-plugin-react-hooks: "npm:^7.0.0" + globals: "npm:16.4.0" + typescript-eslint: "npm:^8.46.0" + peerDependencies: + eslint: ">=9.0.0" + typescript: ">=3.3.1" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/c6fd3accadb53c636f034baf4363d22847bf824c8ca1ecfa8047a4eee7882d156e75f60f37098357c7ae07e646dfaa23a176336abd3c74aa9a2df61aee984653 + languageName: node + linkType: hard + "eslint-config-prettier@npm:10.1.8": version: 10.1.8 resolution: "eslint-config-prettier@npm:10.1.8" @@ -8942,12 +9046,12 @@ __metadata: class-variance-authority: "npm:^0.7.1" clsx: "npm:^2.1.1" eslint: "npm:^9" - eslint-config-next: "npm:16.1.6" + eslint-config-next: "npm:16.2.3" happy-dom: "npm:^20.8.8" jest-axe: "npm:^10.0.0" lucide-react: "npm:^0.563.0" madge: "npm:^8.0.0" - next: "npm:16.1.6" + next: "npm:16.2.3" next-intl: "npm:^4.8.2" next-themes: "npm:^0.4.6" prettier: "npm:^3.4.2" @@ -9858,6 +9962,66 @@ __metadata: languageName: node linkType: hard +"next@npm:16.2.3": + version: 16.2.3 + resolution: "next@npm:16.2.3" + dependencies: + "@next/env": "npm:16.2.3" + "@next/swc-darwin-arm64": "npm:16.2.3" + "@next/swc-darwin-x64": "npm:16.2.3" + "@next/swc-linux-arm64-gnu": "npm:16.2.3" + "@next/swc-linux-arm64-musl": "npm:16.2.3" + "@next/swc-linux-x64-gnu": "npm:16.2.3" + "@next/swc-linux-x64-musl": "npm:16.2.3" + "@next/swc-win32-arm64-msvc": "npm:16.2.3" + "@next/swc-win32-x64-msvc": "npm:16.2.3" + "@swc/helpers": "npm:0.5.15" + baseline-browser-mapping: "npm:^2.9.19" + caniuse-lite: "npm:^1.0.30001579" + postcss: "npm:8.4.31" + sharp: "npm:^0.34.5" + styled-jsx: "npm:5.1.6" + peerDependencies: + "@opentelemetry/api": ^1.1.0 + "@playwright/test": ^1.51.1 + babel-plugin-react-compiler: "*" + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + dependenciesMeta: + "@next/swc-darwin-arm64": + optional: true + "@next/swc-darwin-x64": + optional: true + "@next/swc-linux-arm64-gnu": + optional: true + "@next/swc-linux-arm64-musl": + optional: true + "@next/swc-linux-x64-gnu": + optional: true + "@next/swc-linux-x64-musl": + optional: true + "@next/swc-win32-arm64-msvc": + optional: true + "@next/swc-win32-x64-msvc": + optional: true + sharp: + optional: true + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + "@playwright/test": + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + bin: + next: dist/bin/next + checksum: 10c0/8a9d27fc773d69f7f471cf1a23bde2ab2950e0411ef3e0d5c1664ed9654e94c3304eae1c4283ec0fa4e70e7b3f4416913350e118e0c18e8b055693dc5d021883 + languageName: node + linkType: hard + "node-abi@npm:^3.3.0": version: 3.89.0 resolution: "node-abi@npm:3.89.0" @@ -11302,7 +11466,7 @@ __metadata: languageName: node linkType: hard -"sharp@npm:^0.34.4": +"sharp@npm:^0.34.4, sharp@npm:^0.34.5": version: 0.34.5 resolution: "sharp@npm:0.34.5" dependencies: