From c0995a93061a5cb3eb4c3ae1bf3a58ad45b71d6c Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sat, 28 Mar 2026 09:47:13 +0900 Subject: [PATCH] feat(plugin): add TDD cycle mini progress bar in PreToolUse (#1035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add build_tdd_indicator() that renders current TDD phase as a visual progress bar [RED ● GREEN ○ REFACTOR ○] in the spinner status message. Phase is read from CODINGBUDDY_TDD_PHASE env var. Supports optional cycle count display. --- .../hooks/lib/tdd_progress.py | 56 ++++++++++++ .../claude-code-plugin/hooks/pre-tool-use.py | 9 ++ .../hooks/tests/test_tdd_progress.py | 87 +++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 packages/claude-code-plugin/hooks/lib/tdd_progress.py create mode 100644 packages/claude-code-plugin/hooks/tests/test_tdd_progress.py diff --git a/packages/claude-code-plugin/hooks/lib/tdd_progress.py b/packages/claude-code-plugin/hooks/lib/tdd_progress.py new file mode 100644 index 00000000..2e60d134 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/tdd_progress.py @@ -0,0 +1,56 @@ +"""TDD cycle mini progress bar for status display (#1035). + +Builds a visual indicator showing current TDD phase progress: + [RED ● GREEN ○ REFACTOR ○] + +Reads phase from CODINGBUDDY_TDD_PHASE env var or explicit argument. +""" +import os +from typing import Optional + +# TDD phases in sequential order +TDD_PHASES = ["RED", "GREEN", "REFACTOR"] + +# Display symbols +ACTIVE = "\u25cf" # ● +INACTIVE = "\u25cb" # ○ + + +def build_tdd_indicator( + phase: Optional[str] = None, + cycle_count: Optional[int] = None, +) -> Optional[str]: + """Build a one-line TDD cycle progress indicator. + + Args: + phase: Current TDD phase (RED, GREEN, REFACTOR). + If None, reads from CODINGBUDDY_TDD_PHASE env var. + cycle_count: Number of completed TDD cycles. Appended as '#N' + when > 0. + + Returns: + Formatted string like '[RED ● GREEN ○ REFACTOR ○]' or None + if no valid phase is active. + """ + if phase is None: + phase = os.environ.get("CODINGBUDDY_TDD_PHASE", "") + + if not phase: + return None + + phase = phase.upper().strip() + if phase not in TDD_PHASES: + return None + + phase_index = TDD_PHASES.index(phase) + parts = [] + for i, p in enumerate(TDD_PHASES): + symbol = ACTIVE if i <= phase_index else INACTIVE + parts.append(f"{p} {symbol}") + + indicator = f"[{' '.join(parts)}]" + + if cycle_count and cycle_count > 0: + indicator = f"{indicator} #{cycle_count}" + + return indicator diff --git a/packages/claude-code-plugin/hooks/pre-tool-use.py b/packages/claude-code-plugin/hooks/pre-tool-use.py index 8c168c62..9d1bf683 100644 --- a/packages/claude-code-plugin/hooks/pre-tool-use.py +++ b/packages/claude-code-plugin/hooks/pre-tool-use.py @@ -25,6 +25,7 @@ from config import get_config from agent_status import build_status_message from adaptive_perf import get_monitor +from tdd_progress import build_tdd_indicator # Pattern to detect git commit in a command string _GIT_COMMIT_RE = re.compile(r"\bgit\s+commit\b") @@ -179,6 +180,14 @@ def _handle(data: dict) -> Optional[dict]: # Build agent status message for spinner (#974) — applies to ALL tools status_msg = build_status_message() + # Append TDD cycle progress indicator (#1035) + tdd_indicator = build_tdd_indicator() + if tdd_indicator: + if status_msg: + status_msg = f"{status_msg} {tdd_indicator}" + else: + status_msg = tdd_indicator + tool_name = data.get("tool_name", "") contexts = [] diff --git a/packages/claude-code-plugin/hooks/tests/test_tdd_progress.py b/packages/claude-code-plugin/hooks/tests/test_tdd_progress.py new file mode 100644 index 00000000..2cc8a2b5 --- /dev/null +++ b/packages/claude-code-plugin/hooks/tests/test_tdd_progress.py @@ -0,0 +1,87 @@ +"""Tests for tdd_progress module (#1035).""" +import os +import sys + +import pytest + +# Add lib to path +_hooks_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_lib_dir = os.path.join(_hooks_dir, "lib") +if _lib_dir not in sys.path: + sys.path.insert(0, _lib_dir) + +from tdd_progress import build_tdd_indicator + + +class TestBuildTddIndicator: + """Test build_tdd_indicator function.""" + + def test_red_phase(self): + result = build_tdd_indicator("RED") + assert result == "[RED \u25cf GREEN \u25cb REFACTOR \u25cb]" + + def test_green_phase(self): + result = build_tdd_indicator("GREEN") + assert result == "[RED \u25cf GREEN \u25cf REFACTOR \u25cb]" + + def test_refactor_phase(self): + result = build_tdd_indicator("REFACTOR") + assert result == "[RED \u25cf GREEN \u25cf REFACTOR \u25cf]" + + def test_none_returns_none(self): + assert build_tdd_indicator(None) is None + + def test_empty_string_returns_none(self): + assert build_tdd_indicator("") is None + + def test_invalid_phase_returns_none(self): + assert build_tdd_indicator("INVALID") is None + + def test_lowercase_phase(self): + result = build_tdd_indicator("red") + assert result == "[RED \u25cf GREEN \u25cb REFACTOR \u25cb]" + + def test_mixed_case_phase(self): + result = build_tdd_indicator("Green") + assert result == "[RED \u25cf GREEN \u25cf REFACTOR \u25cb]" + + def test_whitespace_trimmed(self): + result = build_tdd_indicator(" RED ") + assert result == "[RED \u25cf GREEN \u25cb REFACTOR \u25cb]" + + +class TestBuildTddIndicatorFromEnv: + """Test build_tdd_indicator reads from environment when no arg given.""" + + def test_reads_env_var(self, monkeypatch): + monkeypatch.setenv("CODINGBUDDY_TDD_PHASE", "GREEN") + result = build_tdd_indicator() + assert result == "[RED \u25cf GREEN \u25cf REFACTOR \u25cb]" + + def test_no_env_var_returns_none(self, monkeypatch): + monkeypatch.delenv("CODINGBUDDY_TDD_PHASE", raising=False) + assert build_tdd_indicator() is None + + def test_empty_env_var_returns_none(self, monkeypatch): + monkeypatch.setenv("CODINGBUDDY_TDD_PHASE", "") + assert build_tdd_indicator() is None + + +class TestBuildTddIndicatorWithCycleCount: + """Test TDD indicator with cycle count display.""" + + def test_red_with_cycle_count(self): + result = build_tdd_indicator("RED", cycle_count=3) + assert result == "[RED \u25cf GREEN \u25cb REFACTOR \u25cb] #3" + + def test_green_with_cycle_count(self): + result = build_tdd_indicator("GREEN", cycle_count=1) + assert result == "[RED \u25cf GREEN \u25cf REFACTOR \u25cb] #1" + + def test_zero_cycle_count_omitted(self): + result = build_tdd_indicator("RED", cycle_count=0) + assert result == "[RED \u25cf GREEN \u25cb REFACTOR \u25cb]" + + def test_none_cycle_count_omitted(self): + result = build_tdd_indicator("RED", cycle_count=None) + assert result == "[RED \u25cf GREEN \u25cb REFACTOR \u25cb]"