Files
XDJ100SX/tools/xdj_pi_dev/_terminal.py
T
Jeancarlo 8b0eb42fec add MCP developer tool with multi-unit support
First developer tool for the XDJ-100SX project. Connects Claude Code
directly to the Pi over SSH — push skin files, take screenshots, restart
Mixxx, flash Pico firmware, and more without leaving the editor.

Available MCP tools:
- run_command, read_file, write_file, list_files
- push_skin, pull_skin, push_skin_file, pull_skin_file
- push_midi, pull_midi
- take_screenshot, navigate_panel
- restart_mixxx
- check (preflight: SSH, Mixxx, Pico, audio)
- pico_bootloader, pico_flash
- discover_units — scan network for all reachable XDJ Pi units
- select_unit — switch active connection mid-session (multi-unit support)

Also adds --about flag and TUI About modal with authors and credits,
and fixes scrolling/close behavior on Help and About modals.

By: Jeancarlo Cardoso de Faria Filho (jaianlab) <jaianlabworks@gmail.com>
2026-05-08 01:24:15 -03:00

159 lines
4.8 KiB
Python

from __future__ import annotations
"""
Shared terminal UI helpers.
Extracted from xdj-pi-dev.py so all sub-modules can import consistent
colour, spinner, and logging primitives without circular dependencies.
"""
import itertools
import shutil
import sys
import threading
from typing import Any
# ─── ANSI colour ─────────────────────────────────────────────────────────────
def _ansi_enable() -> bool:
"""Enable ANSI on Windows; return True if terminal supports colour."""
if not sys.stdout.isatty():
return False
if sys.platform == "win32":
try:
import ctypes
k = ctypes.windll.kernel32
k.SetConsoleMode(k.GetStdHandle(-11), 7)
except Exception:
return False
return True
_COLOR = _ansi_enable()
class _C:
RESET = "\033[0m" if _COLOR else ""
BOLD = "\033[1m" if _COLOR else ""
DIM = "\033[2m" if _COLOR else ""
GREEN = "\033[32m" if _COLOR else ""
YELLOW = "\033[33m" if _COLOR else ""
RED = "\033[31m" if _COLOR else ""
CYAN = "\033[36m" if _COLOR else ""
GRAY = "\033[90m" if _COLOR else ""
ERASE = "\r\033[K" if _COLOR else "\r"
_SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
# ─── Spinner step ─────────────────────────────────────────────────────────────
class Step:
"""
Context manager for a named operation with a live spinner.
with Step("Stopping Mixxx"):
pi_client.exec("killall mixxx")
Prints ⠹ Stopping Mixxx... while running,
then ✓ Stopping Mixxx on success,
or ✗ Stopping Mixxx on exception.
"""
def __init__(self, label: str, indent: int = 2) -> None:
self.label = label
self.indent = " " * indent
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def __enter__(self) -> "Step":
self._stop.clear()
self._thread = threading.Thread(target=self._spin, daemon=True)
self._thread.start()
return self
def _spin(self) -> None:
for frame in itertools.cycle(_SPIN_FRAMES):
sys.stdout.write(
f"{_C.ERASE}{self.indent}{_C.CYAN}{frame}{_C.RESET}"
f" {self.label}"
)
sys.stdout.flush()
if self._stop.wait(0.08):
break
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self._stop.set()
if self._thread:
self._thread.join()
if exc_type is None:
sys.stdout.write(
f"{_C.ERASE}{self.indent}{_C.GREEN}{_C.RESET}"
f" {self.label}\n"
)
else:
sys.stdout.write(
f"{_C.ERASE}{self.indent}{_C.RED}{_C.RESET}"
f" {self.label} {_C.DIM}({exc_val}){_C.RESET}\n"
)
sys.stdout.flush()
return False # don't suppress exceptions
# ─── Log routing ──────────────────────────────────────────────────────────────
# Set by XDJApp when TUI is active; (level, msg) -> None where level in ok/warn/fail/info/head
_tui_log_fn: Any = None
def section(title: str) -> None:
if _tui_log_fn:
_tui_log_fn("head", title)
return
w = shutil.get_terminal_size((72, 24)).columns
bar = "" * min(w, 72)
print(f"\n{_C.BOLD}{bar}{_C.RESET}")
print(f" {_C.BOLD}{title}{_C.RESET}")
print(f"{_C.BOLD}{bar}{_C.RESET}")
def ok(msg: str) -> None:
if _tui_log_fn:
_tui_log_fn("ok", msg)
return
print(f" {_C.GREEN}{_C.RESET} {msg}")
def warn(msg: str) -> None:
if _tui_log_fn:
_tui_log_fn("warn", msg)
return
print(f" {_C.YELLOW}{_C.RESET} {msg}")
def fail(msg: str) -> None:
if _tui_log_fn:
_tui_log_fn("fail", msg)
return
print(f" {_C.RED}{_C.RESET} {msg}")
def _log_line(line: str) -> None:
lo = line.lower()
if _tui_log_fn:
if any(x in lo for x in ("error:", "fatal error:", " error ")):
_tui_log_fn("fail", line)
elif "warning:" in lo:
_tui_log_fn("warn", line)
elif line.strip():
_tui_log_fn("info", line)
return
if any(x in lo for x in ("error:", "fatal error:", " error ")):
print(f" {_C.RED}{_C.RESET} {_C.RED}{line}{_C.RESET}")
elif "warning:" in lo:
print(f" {_C.YELLOW}{_C.RESET} {_C.YELLOW}{line}{_C.RESET}")
elif line.strip():
print(f" {_C.GRAY}{_C.RESET} {line}")