|
1 | | -"""Adaptive layout engine for CodingBuddy statusLine (#1326). |
| 1 | +"""Adaptive layout engine for CodingBuddy statusLine (#1326, Wave 1-D). |
2 | 2 |
|
3 | | -Wave 0 skeleton — reserved for **Wave 1-D**. |
| 3 | +Provides width-aware segment rendering so the status bar never spills |
| 4 | +out of the terminal. The core primitives are: |
4 | 5 |
|
5 | | -Planned contents (Wave 1-D owner fills): |
6 | | - * ``SEGMENT_PRIORITY: list[tuple[str, int]]`` — drop order when |
7 | | - width-constrained |
8 | | - * ``_visible_len(s: str) -> int`` — ANSI-aware length |
9 | | - * ``_shorten_model_label(name: str, *, compact: bool = False) -> str`` |
10 | | - * ``_fit_segments(segments: list[str], width: int, *, separator: str) -> str`` |
| 6 | +- :data:`SEGMENT_PRIORITY` — canonical drop order for Wave 3 integrator |
| 7 | +- :data:`SACRED_PRIORITY` — threshold below which segments are never dropped |
| 8 | +- :func:`visible_len` — width-aware character count (emoji/CJK = 2 cols) |
| 9 | +- :func:`terminal_width` — shutil-backed width detection with fallback |
| 10 | +- :func:`shorten_model_label` — compact Claude model-name helper |
| 11 | +- :func:`fit_segments` — priority-based assembly with overflow truncation |
11 | 12 |
|
12 | | -Wave 1-D will also migrate the segment-assembly logic currently inline |
13 | | -in ``codingbuddy-hud.format_status_line`` to these helpers. Until then, |
14 | | -this file is a reserved import target so Wave workers downstream |
15 | | -(Wave 2-E, Wave 3) can reference ``hud_layout`` without creating it. |
| 13 | +Wave 1-D only ships the layout helpers. Wave 3 integrator wires them |
| 14 | +into ``format_status_line``; until then the monolith continues to |
| 15 | +build its status line inline and this module is consumed only by |
| 16 | +the new tests. |
16 | 17 | """ |
| 18 | +from __future__ import annotations |
| 19 | + |
| 20 | +import re |
| 21 | +import shutil |
| 22 | +import unicodedata |
| 23 | +from typing import List, Tuple |
| 24 | + |
| 25 | +# ------------------------------------------------------------------------ |
| 26 | +# Constants |
| 27 | +# ------------------------------------------------------------------------ |
| 28 | + |
| 29 | +#: Canonical drop order for statusLine segments. Priority is ascending; |
| 30 | +#: higher numbers are dropped first when width is tight. Entries at or |
| 31 | +#: below :data:`SACRED_PRIORITY` are never dropped. |
| 32 | +SEGMENT_PRIORITY: List[Tuple[str, int]] = [ |
| 33 | + ("face_version", 0), # sacred: "◕‿◕ CB v5.5.0" |
| 34 | + ("mode_health", 1), # sacred: "PLAN 🟢" |
| 35 | + ("cost", 2), |
| 36 | + ("duration", 3), |
| 37 | + ("ctx", 4), |
| 38 | + ("cache", 5), |
| 39 | + ("model", 6), |
| 40 | + ("rate_limits", 7), |
| 41 | + ("worktree", 8), |
| 42 | +] |
| 43 | + |
| 44 | +#: Priorities ``<= SACRED_PRIORITY`` are never dropped by :func:`fit_segments`. |
| 45 | +SACRED_PRIORITY: int = 1 |
| 46 | + |
| 47 | +#: Default separator used between segments. |
| 48 | +DEFAULT_SEPARATOR: str = " | " |
| 49 | + |
| 50 | +#: Fallback terminal width when ``shutil.get_terminal_size`` cannot |
| 51 | +#: report a real value (tests, pipes, detached TTYs). |
| 52 | +FALLBACK_TERMINAL_WIDTH: int = 120 |
| 53 | + |
| 54 | +#: Single-character ellipsis glyph used for hard truncation. |
| 55 | +_ELLIPSIS: str = "\u2026" # … |
| 56 | + |
| 57 | + |
| 58 | +# ------------------------------------------------------------------------ |
| 59 | +# Width helpers |
| 60 | +# ------------------------------------------------------------------------ |
| 61 | + |
| 62 | + |
| 63 | +def visible_len(s: str) -> int: |
| 64 | + """Approximate the visible column count of ``s``. |
| 65 | +
|
| 66 | + Emoji, CJK, and other full-width characters count as 2 columns; |
| 67 | + everything else counts as 1. This mirrors how most monospaced |
| 68 | + terminals render the corresponding glyphs. |
| 69 | +
|
| 70 | + Note: ANSI escape sequences are NOT stripped. When Wave 2-D adds |
| 71 | + ANSI coloring, callers that mix coloring with layout must strip |
| 72 | + escapes before passing to this function. |
| 73 | + """ |
| 74 | + width = 0 |
| 75 | + for ch in s: |
| 76 | + if unicodedata.east_asian_width(ch) in ("W", "F"): |
| 77 | + width += 2 |
| 78 | + else: |
| 79 | + width += 1 |
| 80 | + return width |
| 81 | + |
| 82 | + |
| 83 | +def terminal_width(*, fallback: int = FALLBACK_TERMINAL_WIDTH) -> int: |
| 84 | + """Return the current terminal width with a safe fallback. |
| 85 | +
|
| 86 | + Uses ``shutil.get_terminal_size`` and degrades to *fallback* |
| 87 | + whenever the call raises or reports a non-positive column count. |
| 88 | + """ |
| 89 | + try: |
| 90 | + size = shutil.get_terminal_size((fallback, 20)) |
| 91 | + return size.columns if size.columns > 0 else fallback |
| 92 | + except Exception: |
| 93 | + return fallback |
| 94 | + |
| 95 | + |
| 96 | +# ------------------------------------------------------------------------ |
| 97 | +# Model label helper |
| 98 | +# ------------------------------------------------------------------------ |
| 99 | + |
| 100 | +_CONTEXT_SUFFIX_RE = re.compile(r"\s*\([^)]*context\)\s*$", re.IGNORECASE) |
| 101 | +_COMPACT_PATTERN_RE = re.compile(r"(\w+).*?(\d+[KMG])", re.IGNORECASE) |
| 102 | + |
| 103 | + |
| 104 | +def shorten_model_label(name: str, *, compact: bool = False) -> str: |
| 105 | + """Produce a compact version of a Claude model display name. |
| 106 | +
|
| 107 | + Normal mode (compact=False, default) just strips the trailing |
| 108 | + ``" (1M context)"`` marker so a long display name like |
| 109 | + ``"Opus 4.6 (1M context)"`` becomes ``"Opus 4.6"``. |
| 110 | +
|
| 111 | + Compact mode (compact=True) extracts the model family and the |
| 112 | + context size into a tight ``Family(NM)`` pattern so the string |
| 113 | + fits in very narrow terminals. If no context marker is present, |
| 114 | + only the first whitespace-separated token is returned. |
| 115 | +
|
| 116 | + Examples: |
| 117 | +
|
| 118 | + >>> shorten_model_label("Opus 4.6 (1M context)") |
| 119 | + 'Opus 4.6' |
| 120 | + >>> shorten_model_label("Opus 4.6 (1M context)", compact=True) |
| 121 | + 'Opus(1M)' |
| 122 | + >>> shorten_model_label("Sonnet 4.5") |
| 123 | + 'Sonnet 4.5' |
| 124 | + >>> shorten_model_label("Sonnet 4.5", compact=True) |
| 125 | + 'Sonnet' |
| 126 | + >>> shorten_model_label("") |
| 127 | + '' |
| 128 | + """ |
| 129 | + if not name: |
| 130 | + return "" |
| 131 | + |
| 132 | + if not compact: |
| 133 | + return _CONTEXT_SUFFIX_RE.sub("", name).strip() |
| 134 | + |
| 135 | + match = _COMPACT_PATTERN_RE.match(name) |
| 136 | + if match: |
| 137 | + return f"{match.group(1)}({match.group(2)})" |
| 138 | + |
| 139 | + parts = name.split() |
| 140 | + return parts[0] if parts else name |
| 141 | + |
| 142 | + |
| 143 | +# ------------------------------------------------------------------------ |
| 144 | +# Fit segments |
| 145 | +# ------------------------------------------------------------------------ |
| 146 | + |
| 147 | + |
| 148 | +def fit_segments( |
| 149 | + segments: List[Tuple[str, int, str]], |
| 150 | + width: int, |
| 151 | + *, |
| 152 | + separator: str = DEFAULT_SEPARATOR, |
| 153 | +) -> str: |
| 154 | + """Render segments with priority-based drop-until-fit semantics. |
| 155 | +
|
| 156 | + Args: |
| 157 | + segments: List of ``(name, priority, text)`` tuples. ``name`` |
| 158 | + is a caller-supplied identifier (ignored during render), |
| 159 | + ``priority`` is the drop order (higher = dropped first, |
| 160 | + 0/1 are sacred), and ``text`` is the literal text. |
| 161 | + width: Maximum visible column count. Rendering tries to fit |
| 162 | + within this budget by dropping the lowest-priority |
| 163 | + (highest number) segments first. |
| 164 | + separator: String inserted between kept segments. Defaults to |
| 165 | + :data:`DEFAULT_SEPARATOR` (`` | ``). |
| 166 | +
|
| 167 | + Returns: |
| 168 | + The assembled status line. When even the sacred segments |
| 169 | + (priority ``<= SACRED_PRIORITY``) exceed the budget, the |
| 170 | + result is hard-truncated with a trailing U+2026 (``…``). |
| 171 | +
|
| 172 | + Contract: |
| 173 | + * Empty text segments are always skipped. |
| 174 | + * Priority ≤ SACRED_PRIORITY segments are NEVER dropped. |
| 175 | + * Output preserves the caller-provided segment order. |
| 176 | + """ |
| 177 | + # Drop empty text up-front so they don't contribute to width or |
| 178 | + # produce double separators. |
| 179 | + non_empty = [(n, p, t) for n, p, t in segments if t] |
| 180 | + |
| 181 | + def render(segs: List[Tuple[str, int, str]]) -> str: |
| 182 | + return separator.join(t for _, _, t in segs) |
| 183 | + |
| 184 | + # Try rendering everything first — the common case. |
| 185 | + line = render(non_empty) |
| 186 | + if visible_len(line) <= width: |
| 187 | + return line |
| 188 | + |
| 189 | + # Drop segments from highest priority number down until fit or |
| 190 | + # only sacred segments remain. |
| 191 | + kept = list(non_empty) |
| 192 | + droppable_priorities = sorted( |
| 193 | + {p for _, p, _ in kept if p > SACRED_PRIORITY}, |
| 194 | + reverse=True, |
| 195 | + ) |
| 196 | + for p in droppable_priorities: |
| 197 | + kept = [s for s in kept if s[1] != p] |
| 198 | + line = render(kept) |
| 199 | + if visible_len(line) <= width: |
| 200 | + return line |
| 201 | + |
| 202 | + # Even sacred segments alone don't fit — hard truncate. |
| 203 | + line = render(kept) |
| 204 | + if visible_len(line) > width: |
| 205 | + return _hard_truncate(line, width) |
| 206 | + return line |
| 207 | + |
| 208 | + |
| 209 | +def _hard_truncate(s: str, width: int) -> str: |
| 210 | + """Truncate ``s`` to ``width`` visible columns with trailing ellipsis. |
| 211 | +
|
| 212 | + Walks characters left-to-right until the visible budget (minus |
| 213 | + one column reserved for the ``…`` glyph) is consumed. Returns |
| 214 | + just the ellipsis when ``width <= 1``. |
| 215 | + """ |
| 216 | + if width <= 0: |
| 217 | + return "" |
| 218 | + if width == 1: |
| 219 | + return _ELLIPSIS |
| 220 | + budget = width - 1 # reserve 1 column for the ellipsis |
| 221 | + result: list = [] |
| 222 | + cost = 0 |
| 223 | + for ch in s: |
| 224 | + ch_width = 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 |
| 225 | + if cost + ch_width > budget: |
| 226 | + break |
| 227 | + result.append(ch) |
| 228 | + cost += ch_width |
| 229 | + return "".join(result) + _ELLIPSIS |
0 commit comments