From da01c277d1225e701bbe733a40b0a819f5277e87 Mon Sep 17 00:00:00 2001 From: DJ Date: Mon, 6 Jul 2026 22:30:15 -0400 Subject: [PATCH 1/2] fix: bright-yellow SGR crashes TUI log render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pyte names SGR 93 'brightbrown'; _PYTE_COLOR_FIX remapped it to the already-prefixed 'bright_yellow', which _rich_color then re-prefixed into the invalid 'bright__yellow' — raising ColorParseError and blanking the dashboard log pane on the first bright-yellow line (common in agent output). Keep the remap in pyte's underscore-free namespace ('brightyellow') so the 'bright' -> 'bright_' transform produces a valid 'bright_yellow'. Add a regression test asserting every mapped color parses via rich. --- src/bmad_loop/tui/data.py | 7 +++++-- tests/test_tui_data.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/bmad_loop/tui/data.py b/src/bmad_loop/tui/data.py index b0beadd..e855691 100644 --- a/src/bmad_loop/tui/data.py +++ b/src/bmad_loop/tui/data.py @@ -269,8 +269,11 @@ def read_new(self) -> list[dict[str, Any]]: _PANE_LINES = 50 _HISTORY_LINES = 2000 # matches the dashboard RichLog max_lines -# pyte names SGR 33 "brown"; aixterm brights carry no underscore. -_PYTE_COLOR_FIX = {"brown": "yellow", "brightbrown": "bright_yellow"} +# pyte names SGR 33 "brown"; aixterm brights carry no underscore. Values here +# must stay in pyte's own (underscore-free) namespace: _rich_color re-applies +# the "bright" -> "bright_" transform after this remap, so a "bright_"-prefixed +# value would double up into "bright__yellow" (an invalid rich color). +_PYTE_COLOR_FIX = {"brown": "yellow", "brightbrown": "brightyellow"} _HEX_DIGITS = set("0123456789abcdef") diff --git a/tests/test_tui_data.py b/tests/test_tui_data.py index 35e8444..00e3db1 100644 --- a/tests/test_tui_data.py +++ b/tests/test_tui_data.py @@ -888,3 +888,27 @@ def test_run_watcher_state_refreshes_on_same_size_rewrite(tmp_path): assert after.st_size == pinned.st_size and after.st_mtime_ns == pinned.st_mtime_ns assert watcher.state() is not first # re-parsed because the inode changed + + +def test_rich_color_maps_pyte_names_to_valid_rich_colors(): + # pyte emits aixterm bright names without an underscore (e.g. "brightbrown" + # for SGR 93). _rich_color remaps pyte's "brown"/"brightbrown" and then + # applies the "bright" -> "bright_" transform; the remap target must stay in + # pyte's underscore-free namespace or the transform doubles the underscore + # into an invalid "bright__yellow" and every log render raises ColorParseError. + from rich.color import Color + + cases = { + "default": None, + "brown": "yellow", + "brightbrown": "bright_yellow", # regression: was "bright__yellow" + "red": "red", + "brightred": "bright_red", + "brightyellow": "bright_yellow", + "ff00aa": "#ff00aa", + } + for pyte_name, expected in cases.items(): + got = data._rich_color(pyte_name) + assert got == expected, f"{pyte_name!r} -> {got!r}, expected {expected!r}" + if got is not None: + Color.parse(got) # must be a color rich accepts, else the TUI crashes From e592059c22920a8d3d2ba6a28e0f598645334558 Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 8 Jul 2026 20:18:31 -0700 Subject: [PATCH 2/2] fix(tui): remap pyte's bfightmagenta typo, degrade unparseable colors Completes the bright__yellow fix for the whole failure class: - pyte 0.8.2's BG_AIXTERM[105] is misspelled "bfightmagenta"; it passed through _rich_color untouched and raised the same ColorParseError on any background bright-magenta (SGR 105). Remap it alongside brown/brightbrown. - Sweep every name in pyte's FG/BG/FG_AIXTERM/BG_AIXTERM tables through _rich_color in the regression test so a pyte bump that adds or renames entries fails in CI, not in the dashboard's poll worker. - Guard _char_style: an unparseable color now degrades to an uncolored run instead of killing the poll worker (textual workers default to exit_on_error=True, so that crash could take the whole TUI down). --- src/bmad_loop/tui/data.py | 20 ++++++++++++++++---- tests/test_tui_data.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/bmad_loop/tui/data.py b/src/bmad_loop/tui/data.py index e855691..b66c415 100644 --- a/src/bmad_loop/tui/data.py +++ b/src/bmad_loop/tui/data.py @@ -22,6 +22,7 @@ from typing import Any import pyte +from rich.color import ColorParseError from rich.style import Style from rich.text import Text @@ -273,7 +274,12 @@ def read_new(self) -> list[dict[str, Any]]: # must stay in pyte's own (underscore-free) namespace: _rich_color re-applies # the "bright" -> "bright_" transform after this remap, so a "bright_"-prefixed # value would double up into "bright__yellow" (an invalid rich color). -_PYTE_COLOR_FIX = {"brown": "yellow", "brightbrown": "brightyellow"} +# "bfightmagenta" is pyte 0.8.2's own BG_AIXTERM[105] typo (SGR 105). +_PYTE_COLOR_FIX = { + "brown": "yellow", + "brightbrown": "brightyellow", + "bfightmagenta": "brightmagenta", +} _HEX_DIGITS = set("0123456789abcdef") @@ -297,15 +303,21 @@ def _char_style(key: tuple) -> Style: style = _style_cache.get(key) if style is None: fg, bg, bold, italics, underscore, strikethrough, reverse = key - style = _style_cache[key] = Style( - color=_rich_color(fg), - bgcolor=_rich_color(bg), + attrs = dict( bold=bold or None, italic=italics or None, underline=underscore or None, strike=strikethrough or None, reverse=reverse or None, ) + try: + style = Style(color=_rich_color(fg), bgcolor=_rich_color(bg), **attrs) + except ColorParseError: + # A color name rich can't parse (e.g. a new pyte table entry) must + # degrade to an uncolored run, not kill the poll worker — and with + # it the app (textual workers default to exit_on_error=True). + style = Style(**attrs) + _style_cache[key] = style return style diff --git a/tests/test_tui_data.py b/tests/test_tui_data.py index 00e3db1..c92f994 100644 --- a/tests/test_tui_data.py +++ b/tests/test_tui_data.py @@ -902,6 +902,7 @@ def test_rich_color_maps_pyte_names_to_valid_rich_colors(): "default": None, "brown": "yellow", "brightbrown": "bright_yellow", # regression: was "bright__yellow" + "bfightmagenta": "bright_magenta", # pyte 0.8.2 BG_AIXTERM[105] typo "red": "red", "brightred": "bright_red", "brightyellow": "bright_yellow", @@ -912,3 +913,30 @@ def test_rich_color_maps_pyte_names_to_valid_rich_colors(): assert got == expected, f"{pyte_name!r} -> {got!r}, expected {expected!r}" if got is not None: Color.parse(got) # must be a color rich accepts, else the TUI crashes + + # Exhaustive: every name the installed pyte can emit must map to something + # rich parses — a pyte bump that adds/renames table entries fails here, not + # in the dashboard's poll worker. + import pyte.graphics as graphics + + every_pyte_name = ( + set(graphics.FG.values()) + | set(graphics.BG.values()) + | set(graphics.FG_AIXTERM.values()) + | set(graphics.BG_AIXTERM.values()) + ) + for pyte_name in sorted(every_pyte_name): + got = data._rich_color(pyte_name) + if got is not None: + Color.parse(got) + + +def test_char_style_degrades_unparseable_color_instead_of_raising(): + # Belt to the sweep test's suspenders: if a color name rich can't parse + # ever slips through _rich_color, the run renders uncolored instead of + # killing the poll worker (and, via exit_on_error, the whole app). + key = ("no_such_color", "default", True, False, True, False, False) + style = data._char_style(key) + assert style.color is None and style.bgcolor is None + assert style.bold and style.underline and not style.italic + assert data._char_style(key) is style # fallback is cached like any other