Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/landing-page/__tests__/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/landing-page/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
135 changes: 89 additions & 46 deletions packages/claude-code-plugin/hooks/codingbuddy-hud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"

Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
148 changes: 148 additions & 0 deletions packages/claude-code-plugin/hooks/lib/hud_buddy.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading
Loading