Skip to content

Commit 8f508e1

Browse files
committed
feat(hud): mode rainbow ANSI coloring (Wave 2-D)
Renders the statusLine mode label with per-mode ANSI truecolor gradients + tier-specific glyphs: PLAN -> ◇ blue (solid) ACT -> ◆ green (solid) EVAL -> ◈ purple (solid) AUTO -> ◊ rainbow (6-stop gradient: red->orange->yellow->green->blue->purple) NO_COLOR env var (https://no-color.org) honoured: any non-empty value disables color and returns plain text. New lib/hud_rainbow.py: - MODE_PALETTE: per-mode RGB anchor list - is_color_enabled(env): NO_COLOR check - mode_glyph(mode): per-mode Unicode glyph lookup - gradient_ansi(text, palette): character-by-character RGB wrap - render_mode_rainbow(mode, *, enabled, env): end-to-end renderer - strip_ansi(s): CSI escape removal helper (for tests + layout) 35 new tests cover: NO_COLOR parsing, glyph lookup (4 modes + unknown + case-insensitive), gradient (single/multi/empty), render (all 4 modes + enabled/disabled/env fallback + unknown mode + case-insensitive), strip_ansi (noop/escapes/empty/mixed), MODE_PALETTE schema. 164/164 pass. Part of #1464 (Wave 0 statusbar refactor)
1 parent de622cc commit 8f508e1

2 files changed

Lines changed: 435 additions & 13 deletions

File tree

Lines changed: 217 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,221 @@
1-
"""Mode rainbow ANSI colouring for CodingBuddy statusLine (#1326).
1+
"""Mode rainbow ANSI coloring for CodingBuddy statusLine (#1326, Wave 2-D).
22
3-
Wave 0 skeleton — reserved for **Wave 2-D**.
3+
Renders the statusLine mode label with per-mode ANSI coloring and a
4+
tier-specific glyph so users can tell at a glance which workflow
5+
mode is active:
46
5-
Planned contents (Wave 2-D owner fills):
6-
* ``MODE_PALETTE: dict[str, tuple[int, int, int]]`` — per-mode RGB
7-
gradient anchors (PLAN/ACT/EVAL/AUTO)
8-
* ``gradient_ansi(text: str, palette: tuple) -> str``
9-
* ``render_mode_rainbow(mode: str, text: str) -> str``
7+
PLAN → ◇ blue (planning — cool, deliberate)
8+
ACT → ◆ green (executing — go)
9+
EVAL → ◈ purple (evaluating — reflective)
10+
AUTO → ◊ rainbow (cycling — energetic gradient)
1011
11-
Wave 2-D will wire the rainbow into ``format_status_line`` (or its
12-
``hud_layout`` successor) in place of the plain text mode label.
12+
Color output honours the ``NO_COLOR`` environment variable
13+
(https://no-color.org) — when it is set to any non-empty value,
14+
:func:`render_mode_rainbow` emits plain text without escape codes so
15+
redirected/logged output stays clean.
16+
17+
Primary entry points:
18+
19+
- :func:`is_color_enabled` — check the NO_COLOR env var
20+
- :func:`mode_glyph` — per-mode Unicode glyph
21+
- :func:`gradient_ansi` — character-by-character RGB gradient
22+
- :func:`render_mode_rainbow` — end-to-end mode label renderer
1323
"""
24+
from __future__ import annotations
25+
26+
import os
27+
from typing import Dict, List, Tuple
28+
29+
# ------------------------------------------------------------------------
30+
# Constants
31+
# ------------------------------------------------------------------------
32+
33+
# ANSI escape codes
34+
_CSI = "\x1b["
35+
_RESET = f"{_CSI}0m"
36+
37+
# Basic 8-bit color codes (38;5;N) for simple modes.
38+
_FG_BLUE = 33 # bright blue
39+
_FG_GREEN = 42 # bright green
40+
_FG_PURPLE = 135 # bright purple
41+
_FG_RED = 196
42+
_FG_ORANGE = 208
43+
_FG_YELLOW = 226
44+
_FG_CYAN = 51
45+
46+
# Per-mode glyphs (mirror AGENT_GLYPHS in codingbuddy-hud.py).
47+
_MODE_GLYPHS: Dict[str, str] = {
48+
"PLAN": "\u25c7", # ◇
49+
"ACT": "\u25c6", # ◆
50+
"EVAL": "\u25c8", # ◈
51+
"AUTO": "\u25ca", # ◊
52+
}
53+
54+
# Per-mode RGB anchor pairs for gradient rendering. AUTO uses a
55+
# 6-stop rainbow; PLAN/ACT/EVAL use a single solid hue applied to
56+
# every character.
57+
MODE_PALETTE: Dict[str, List[Tuple[int, int, int]]] = {
58+
"PLAN": [(64, 128, 255)], # steady blue
59+
"ACT": [(64, 200, 96)], # steady green
60+
"EVAL": [(180, 96, 220)], # steady purple
61+
"AUTO": [
62+
(255, 64, 64), # red
63+
(255, 144, 32), # orange
64+
(255, 224, 32), # yellow
65+
(64, 200, 96), # green
66+
(64, 128, 255), # blue
67+
(180, 96, 220), # purple
68+
],
69+
}
70+
71+
# Reset sequence exposed for callers (tests).
72+
RESET: str = _RESET
73+
74+
75+
# ------------------------------------------------------------------------
76+
# Environment detection
77+
# ------------------------------------------------------------------------
78+
79+
80+
def is_color_enabled(env: Dict[str, str] = None) -> bool: # type: ignore[assignment]
81+
"""Return True when ANSI color output should be produced.
82+
83+
Honours the ``NO_COLOR`` standard (https://no-color.org): any
84+
non-empty value disables color. When ``env`` is omitted, reads
85+
from :data:`os.environ`.
86+
"""
87+
if env is None:
88+
env = os.environ
89+
value = env.get("NO_COLOR", "")
90+
return not value
91+
92+
93+
# ------------------------------------------------------------------------
94+
# Glyph helper
95+
# ------------------------------------------------------------------------
96+
97+
98+
def mode_glyph(mode: str) -> str:
99+
"""Return the Unicode glyph for a mode, or an empty string.
100+
101+
Mode matching is case-insensitive. Unknown modes return
102+
an empty string so callers can render just the text label.
103+
"""
104+
if not mode:
105+
return ""
106+
return _MODE_GLYPHS.get(mode.upper(), "")
107+
108+
109+
# ------------------------------------------------------------------------
110+
# Gradient renderer
111+
# ------------------------------------------------------------------------
112+
113+
114+
def _rgb_escape(r: int, g: int, b: int) -> str:
115+
"""Return the ANSI truecolor foreground escape for ``(r, g, b)``."""
116+
return f"{_CSI}38;2;{r};{g};{b}m"
117+
118+
119+
def gradient_ansi(text: str, palette: List[Tuple[int, int, int]]) -> str:
120+
"""Apply a character-by-character gradient to ``text``.
121+
122+
When ``palette`` has a single color, every character is rendered
123+
in that color. When it has multiple colors, each character is
124+
linearly mapped to a stop on the palette so the first character
125+
is ``palette[0]`` and the last character is ``palette[-1]``.
126+
127+
Returns ``text`` unchanged (without escapes) when ``palette`` is
128+
empty or ``text`` is empty.
129+
"""
130+
if not text or not palette:
131+
return text
132+
133+
if len(palette) == 1:
134+
r, g, b = palette[0]
135+
return f"{_rgb_escape(r, g, b)}{text}{_RESET}"
136+
137+
n_chars = len(text)
138+
n_stops = len(palette)
139+
out: List[str] = []
140+
for i, ch in enumerate(text):
141+
# Map character index to palette stop (0..n_stops-1)
142+
stop = int(i * n_stops / max(n_chars, 1))
143+
stop = min(stop, n_stops - 1)
144+
r, g, b = palette[stop]
145+
out.append(f"{_rgb_escape(r, g, b)}{ch}")
146+
out.append(_RESET)
147+
return "".join(out)
148+
149+
150+
# ------------------------------------------------------------------------
151+
# End-to-end renderer
152+
# ------------------------------------------------------------------------
153+
154+
155+
def render_mode_rainbow(
156+
mode: str,
157+
*,
158+
enabled: bool = None, # type: ignore[assignment]
159+
env: Dict[str, str] = None, # type: ignore[assignment]
160+
) -> str:
161+
"""Render the mode label with glyph + ANSI color.
162+
163+
Output format:
164+
165+
``<glyph> <MODE>``
166+
167+
The label text is the uppercased mode name. When color is
168+
enabled, the whole string (glyph + space + label) is wrapped
169+
in the mode's palette via :func:`gradient_ansi`. When color
170+
is disabled, plain text is returned.
171+
172+
Args:
173+
mode: Workflow mode name (case-insensitive: PLAN/ACT/EVAL/AUTO).
174+
enabled: Explicit override. ``None`` (default) defers to
175+
:func:`is_color_enabled` which honours ``NO_COLOR``.
176+
env: Optional environment override for testing.
177+
178+
Returns an empty string when ``mode`` is empty.
179+
"""
180+
if not mode:
181+
return ""
182+
183+
mode_upper = mode.upper()
184+
glyph = mode_glyph(mode_upper)
185+
label = f"{glyph} {mode_upper}" if glyph else mode_upper
186+
187+
if enabled is None:
188+
enabled = is_color_enabled(env)
189+
if not enabled:
190+
return label
191+
192+
palette = MODE_PALETTE.get(mode_upper)
193+
if not palette:
194+
return label # unknown mode: plain text
195+
196+
return gradient_ansi(label, palette)
197+
198+
199+
def strip_ansi(s: str) -> str:
200+
"""Remove ANSI CSI sequences from ``s`` (helper for tests and layout).
201+
202+
Simple state machine — recognises ``ESC [`` ... ``m`` escape
203+
runs. Sufficient for the escapes this module emits.
204+
"""
205+
if not s or "\x1b" not in s:
206+
return s
207+
out: List[str] = []
208+
i = 0
209+
n = len(s)
210+
while i < n:
211+
ch = s[i]
212+
if ch == "\x1b" and i + 1 < n and s[i + 1] == "[":
213+
# Skip until 'm'
214+
j = i + 2
215+
while j < n and s[j] != "m":
216+
j += 1
217+
i = j + 1
218+
continue
219+
out.append(ch)
220+
i += 1
221+
return "".join(out)

0 commit comments

Comments
 (0)