Skip to content

Commit 66555ff

Browse files
committed
feat(hud): adaptive layout engine (Wave 1-D)
Introduces width-aware segment rendering primitives so the status bar never spills out of narrow terminals. Wave 1-D ships the layout helpers; Wave 3 integrator wires them into format_status_line. New lib/hud_layout.py: - SEGMENT_PRIORITY canonical drop-order list (9 slots) - SACRED_PRIORITY=1 threshold (face_version, mode_health never dropped) - visible_len(s): East-Asian-aware column count - terminal_width(*, fallback): shutil-backed detection with fallback - shorten_model_label: "Opus 4.6 (1M context)" -> "Opus 4.6" / "Opus(1M)" - fit_segments: drop-until-fit with hard-truncate fallback (U+2026) 33 new tests in test_hud_layout.py cover all helpers, edge cases (empty, zero-width, sacred overflow, custom separator, CJK/emoji width), and SEGMENT_PRIORITY ordering invariants. 188/188 tests pass (Golden 133 + Wave 0 22 + Wave 1-D 33). Part of #1464 (Wave 0 statusbar refactor)
1 parent de622cc commit 66555ff

2 files changed

Lines changed: 506 additions & 16 deletions

File tree

Lines changed: 225 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,229 @@
1-
"""Adaptive layout engine for CodingBuddy statusLine (#1326).
1+
"""Adaptive layout engine for CodingBuddy statusLine (#1326, Wave 1-D).
22
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:
45
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
1112
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.
1617
"""
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

Comments
 (0)