cmd_tui's ModuleNotFoundError guard maps only ("textual", "tomlkit"), but the name that actually surfaces on a no-extras install is rich — so the guard misses, re-raises, and the operator gets a traceback instead of the install hint it exists to print.
The guard
# src/bmad_loop/cli.py:3568-3577
try:
from .tui.app import run_tui
except ModuleNotFoundError as e:
if (e.name or "").partition(".")[0] in ("textual", "tomlkit"):
print(
"error: the TUI requires optional dependencies — uv tool install 'bmad-loop[tui]'",
file=sys.stderr,
)
return 1
raise
Why it never fires
src/bmad_loop/tui/app.py imports rich before textual:
# src/bmad_loop/tui/app.py:20-21
from rich.text import Text
from textual import work
So from .tui.app import run_tui raises with e.name == "rich", which is not in the tuple. The raise arm runs.
Compounding it: rich is declared in no extra and no dependency group at all (pyproject.toml) — it arrives only as a transitive dependency of textual. A guard that enumerates the extra's own package names therefore cannot ever catch it.
Reproduction
Under a sys.meta_path blocker for pyte / rich / textual / tomlkit, reproducing cmd_tui's try/except verbatim:
import .tui.app raised ModuleNotFoundError name='rich'
guard tuple ('textual','tomlkit') matches? False
=> GUARD MISSED -> bare raise, user sees a traceback
Scope
Same family as #650 (cmd_list → tui.data → import pyte), and the two should ship together. They are not the same defect: #650 is a core command that should not need the extra at all, whereas tui genuinely requires it — here the bug is purely that the failure is unhandled rather than explained.
Fix notes
Gate on the failure, not on a package allowlist — the import target is the extra's own module tree, so any ModuleNotFoundError escaping from .tui.app import ... means the extra is not installed. If an allowlist is kept for precision, it must include rich.
The guard test must block pyte, rich, textual and tomlkit. tui/data.py:24's import pyte precedes its rich imports, so a blocker covering only pyte leaves the rich half untested — which is how this survived. Ablation rule applies: restore the narrow tuple and confirm the test reddens.
cmd_tui'sModuleNotFoundErrorguard maps only("textual", "tomlkit"), but the name that actually surfaces on a no-extras install isrich— so the guard misses, re-raises, and the operator gets a traceback instead of the install hint it exists to print.The guard
Why it never fires
src/bmad_loop/tui/app.pyimportsrichbeforetextual:So
from .tui.app import run_tuiraises withe.name == "rich", which is not in the tuple. Theraisearm runs.Compounding it:
richis declared in no extra and no dependency group at all (pyproject.toml) — it arrives only as a transitive dependency oftextual. A guard that enumerates the extra's own package names therefore cannot ever catch it.Reproduction
Under a
sys.meta_pathblocker forpyte/rich/textual/tomlkit, reproducingcmd_tui's try/except verbatim:Scope
Same family as #650 (
cmd_list→tui.data→import pyte), and the two should ship together. They are not the same defect: #650 is a core command that should not need the extra at all, whereastuigenuinely requires it — here the bug is purely that the failure is unhandled rather than explained.Fix notes
Gate on the failure, not on a package allowlist — the import target is the extra's own module tree, so any
ModuleNotFoundErrorescapingfrom .tui.app import ...means the extra is not installed. If an allowlist is kept for precision, it must includerich.The guard test must block
pyte,rich,textualandtomlkit.tui/data.py:24'simport pyteprecedes itsrichimports, so a blocker covering onlypyteleaves therichhalf untested — which is how this survived. Ablation rule applies: restore the narrow tuple and confirm the test reddens.