|
1 | | -"""Mode rainbow ANSI colouring for CodingBuddy statusLine (#1326). |
| 1 | +"""Mode rainbow ANSI coloring for CodingBuddy statusLine (#1326, Wave 2-D). |
2 | 2 |
|
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: |
4 | 6 |
|
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) |
10 | 11 |
|
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 |
13 | 23 | """ |
| 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