From 061406b608c5a77b33259506648e0166367b2b9f Mon Sep 17 00:00:00 2001 From: historia Date: Tue, 25 Aug 2026 14:37:16 -0400 Subject: feat: simpler menu for start/stop backend servers --- app/tests/test_hub.py | 50 +++++++++----- app/tests/test_taskview.py | 60 +++++++++++++++++ app/tests/test_tui.py | 28 ++++++++ app/ui/hub.py | 161 +++++++++++++++++++++++++++++++++------------ app/ui/taskview.py | 23 +++++-- app/ui/tui.py | 3 + 6 files changed, 261 insertions(+), 64 deletions(-) (limited to 'app') diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 68d0f34..9842148 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -323,7 +323,8 @@ class SubmenuStatusTableTests(unittest.TestCase): """First picker screen of every flow repeats the backend status table. Entries themselves stay clean: the configure-backends menu lists flat - actions, and the Start/Stop menu offers only installed backends. + actions, and the Start/Stop menu offers only installed backends' + servers, showing running/stopped inline instead of the table. """ def _capture_menu(self, captured): @@ -580,25 +581,30 @@ class SubmenuStatusTableTests(unittest.TestCase): def test_server_menu_lists_only_installed_backends(self): captured = {} installed = BackendStatus("audiocpp", "audio.cpp", installed=True, - configured=True) + configured=True, + servers=[ServerSpec("audiocpp", "http://127.0.0.1:8080", [])]) remote = BackendStatus("qwen", "qwen-tts", installed=False, configured=False, running=True) gone = BackendStatus("faster", "faster-qwen3-tts", installed=False, configured=False) with patch.object(hub.tui, "menu", self._capture_menu(captured)), \ + patch.object(hub.common, "server_running", + return_value=False), \ patch.object(hub, "detect_all", return_value=[installed, remote, gone]), \ patch.object(hub.shutil, "which", return_value="/x"): result = hub._Hub(None).screen_server() self.assertIs(result, tui.Wizard.BACK) - # Only the installed backend is offered; a running external server - # (remote) can't be stopped from here and must not appear. - self.assertEqual([label for label, _ in captured["options"]], + # Only the installed backend's server is offered; a running external + # server (remote) can't be stopped from here and must not appear. + self.assertEqual([opt[0] for opt in captured["options"]], ["audio.cpp"]) - # The status table still shows all three, states included. - self.assertEqual([row[0] for row in captured["table_rows"]], - ["audio.cpp", "qwen-tts", "faster-qwen3-tts"]) + # The running/stopped state lives in the status table above the + # menu (not on the entries, whose colors the selection bar covers). + self.assertEqual(captured["table_title"], "Server status") + self.assertEqual(captured["table_rows"], + [("audio.cpp", "stopped", "err", "body")]) def test_server_menu_flashes_when_nothing_installed(self): flashed = [] @@ -1961,17 +1967,29 @@ class HubNavigationTests(unittest.TestCase): status = BackendStatus("qwen", "qwen-tts", installed=True, configured=True, servers=specs) registry = [self._info("qwen", "qwen-tts")] - with patch.object(hub.common, "server_running", return_value=False): - titles = self._drive( - ["server", "qwen", "qwen-clone", tui.Wizard.BACK, - tui.Wizard.BACK, tui.Wizard.BACK, tui.Wizard.BACK], - [status], registry) + titles = [] + script = ["server", specs[0], tui.Wizard.BACK, tui.Wizard.BACK] + + def menu(stdscr, title, options, **kwargs): + titles.append(title) + return script.pop(0) + + with patch.object(hub, "REGISTRY", registry), \ + patch.object(hub, "detect_all", return_value=[status]), \ + patch.object(hub.tui, "menu", menu), \ + patch.object(hub.common, "server_running", + return_value=False), \ + patch.object(hub.taskview, "run_steps", return_value=0), \ + patch.object(hub.tui, "flash", lambda *a, **k: None), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=None): + hub._Hub(None).run() + # Selecting a server toggles it directly (no action sub-menu), then + # Esc steps back one screen at a time to the server list and main. self.assertEqual( titles, ["tts-audiobook-generator", "Start / Stop a server", - "qwen-tts server", "qwen-clone (stopped)", - "qwen-tts server", "Start / Stop a server", - "tts-audiobook-generator"]) + "Start / Stop a server", "tts-audiobook-generator"]) def test_esc_on_main_menu_quits(self): titles = self._drive([tui.Wizard.BACK], [], []) diff --git a/app/tests/test_taskview.py b/app/tests/test_taskview.py index d1bc658..5d97902 100644 --- a/app/tests/test_taskview.py +++ b/app/tests/test_taskview.py @@ -212,6 +212,66 @@ class RenderTests(_FakeTui, unittest.TestCase): self.assertIn("Progress", text) +class NoWaitTests(_FakeTui, unittest.TestCase): + """wait_on_finish=False: the view returns to the caller on finish.""" + + def make_view(self, steps=(), width=80, height=24, wait_on_finish=False): + screen = FakeScreen(width=width, height=height) + with patch.object(taskview.TaskView, "_worker_main", lambda self: None): + view = taskview.TaskView(screen, "Setup", list(steps), + clock=lambda: 1000.0, + wait_on_finish=wait_on_finish) + return view, screen + + def _strings(self, screen): + return " ".join(text for _, _, text, _ in screen.strings) + + def _run_view(self, wait_on_finish): + """Run() with a synchronous worker that posts a successful finish.""" + screen = FakeScreen(width=80, height=24) + step = _step("one") + + def fake_worker_main(self): + self._queue.put({"kind": "step_start", "index": 0, + "title": "one"}) + self._queue.put({"kind": "step_done", "index": 0, "rc": 0}) + self._queue.put({"kind": "finish", "phase": "done", "rc": 0}) + + with patch.object(taskview.TaskView, "_worker_main", + fake_worker_main): + view = taskview.TaskView(screen, "Setup", [step], + clock=lambda: 1000.0, + wait_on_finish=wait_on_finish) + # Replace the daemon thread with a synchronous runner so the + # events are queued before run() enters its loop (deterministic). + view._worker = _SyncWorker(view._worker_main) + return view.run() + + def test_returns_immediately_when_not_waiting(self): + # No scripted keys: run() must return on finish without asking for one. + self.assertEqual(self._run_view(wait_on_finish=False), 0) + + def test_done_footer_omits_key_hint_when_not_waiting(self): + view, screen = self.make_view(steps=[_step("one")]) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + view.handle_event({"kind": "step_done", "index": 0, "rc": 0}) + view.handle_event({"kind": "finish", "phase": "done", "rc": 0}) + view.render() + text = self._strings(screen) + self.assertIn("completed", text) + self.assertNotIn("press any key", text) + + +class _SyncWorker: + """A stand-in for threading.Thread that runs the target synchronously.""" + + def __init__(self, fn): + self._fn = fn + + def start(self): + self._fn() + + class LabelTests(unittest.TestCase): def test_fmt_bytes(self): self.assertEqual(taskview._fmt_bytes(512), "512B") diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index 5b5cb3c..030b668 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -50,6 +50,7 @@ class FakeCurses: ACS_LRCORNER = "lr" ACS_VLINE = "v" ACS_HLINE = "h" + ACS_RARROW = "ra" class error(Exception): pass @@ -254,6 +255,33 @@ class MenuTests(TuiTestCase): if drawn == "Build audio.cpp server") self.assertEqual(label_attr, tui._THEME["body"]) + def test_selected_row_has_arrow_in_the_margin(self): + # The highlighted option gets an ACS_RARROW in the blank margin + # left of its text; unselected rows draw none. + screen = FakeScreen(keys=[10]) + tui.menu(screen, "Pick", self.OPTIONS) + x0, _ = self.dialog_box(screen) + arrow_x = x0 + 1 + tui.Frame.LIST_MARGIN - 1 + arrows = [(y, x) for y, x, ch, _ in screen.chars + if ch == FakeCurses.ACS_RARROW] + y_first = next(y for y, _, text, _ in screen.strings + if text == "first option") + self.assertEqual(arrows, [(y_first, arrow_x)]) + + def test_arrow_follows_the_cursor(self): + # Down moves the arrow onto the second option on the redraw. + screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, 10]) + tui.menu(screen, "Pick", self.OPTIONS) + x0, _ = self.dialog_box(screen) + arrow_x = x0 + 1 + tui.Frame.LIST_MARGIN - 1 + arrows = [y for y, x, ch, _ in screen.chars + if ch == FakeCurses.ACS_RARROW and x == arrow_x] + y_first = next(y for y, _, text, _ in screen.strings + if text == "first option") + y_second = next(y for y, _, text, _ in screen.strings + if text == "second option") + self.assertEqual(arrows, [y_first, y_second]) + class MenuTableTests(TuiTestCase): """The optional status table: aligned columns and colored statuses.""" diff --git a/app/ui/hub.py b/app/ui/hub.py index 285a1cf..29edf9b 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -25,6 +25,7 @@ import io import json import re import shutil +import sys import urllib.parse from datetime import datetime from pathlib import Path @@ -366,59 +367,137 @@ class _Hub: # -- servers -------------------------------------------------------- def screen_server(self): + """One flat menu of every installed backend's servers. + + A status table above the menu shows each server's live state — + "running" (green) or "stopped" (red) — and selecting an entry + toggles it directly (starts a stopped server, stops a running one) + without an extra action menu. The state lives in the table, not on + the entries, because the menu's selection bar would cover inline + colors. A backend with a single server is labelled by its name; a + multi-server backend (qwen: CustomVoice + Base) gets one entry per + server, suffixed with the server name. + """ statuses = detect_all() candidates = [st for st in statuses if st.installed] if not candidates: tui.flash(self.stdscr, "No backend is installed yet — use " "'Configure backends' first.") return tui.Wizard.BACK - options = [(st.label, st.key) for st in candidates] - key = tui.menu(self.stdscr, "Start / Stop a server", options, - back_value=tui.Wizard.BACK, - table_title="Backend status", - table_rows=_status_rows(statuses), - notice_lines=_notice_lines()) - if key is tui.Wizard.BACK: - return tui.Wizard.BACK - status = next((s for s in statuses if s.key == key), None) - if status is None: - return tui.Wizard.BACK - specs = status.servers - if not specs: - tui.flash(self.stdscr, f"{status.label} has no server " - "configured. Run 'Configure backends' first.") - return tui.Wizard.BACK - if len(specs) == 1: - return functools.partial(self._server_action, specs[0]) - return functools.partial(self._server_spec, status, specs) - - def _server_spec(self, status, specs): - options = [(f"{s.name} ({'running' if common.server_running(s.url) else 'stopped'})", - s.name) for s in specs] - name = tui.menu(self.stdscr, f"{status.label} server", options, - back_value=tui.Wizard.BACK) - if name is tui.Wizard.BACK: + options = [] + rows = [] + for st in candidates: + specs = st.servers + for spec in specs: + running = common.server_running(spec.url) + label = st.label if len(specs) == 1 \ + else f"{st.label} — {spec.name}" + options.append((label, spec)) + rows.append((label, + "running" if running else "stopped", + "ok" if running else "err", "body")) + if not options: + tui.flash(self.stdscr, "No backend server is configured yet — " + "use 'Configure backends' first.") return tui.Wizard.BACK - spec = next((s for s in specs if s.name == name), None) - if spec is None: + spec = tui.menu(self.stdscr, "Start / Stop a server", options, + back_value=tui.Wizard.BACK, + help_lines=["Select a server to start or stop it."], + table_title="Server status", + table_rows=rows, + notice_lines=_notice_lines()) + if spec is tui.Wizard.BACK: return tui.Wizard.BACK - return functools.partial(self._server_action, spec) + return functools.partial(self._server_toggle, spec) - def _server_action(self, spec): + def _server_toggle(self, spec): + """Start SPEC's server when stopped, stop it when running. + + Runs inside the task view (no console drop); the server module's + plain-console output is tee'd to a log file under ``app/logs`` so + nothing is lost, and on failure a flash points the user at that + file. Returns BACK so the stack lands back on the server list, + which re-reads each server's live state. + """ running = common.server_running(spec.url) - action = tui.menu( - self.stdscr, - f"{spec.name} ({'running' if running else 'stopped'})", - [("Start", "start"), ("Stop", "stop")], - back_value=tui.Wizard.BACK) - if action is tui.Wizard.BACK: - return tui.Wizard.BACK - with tui.suspend(self.stdscr): + action = "stop" if running else "start" + step, log_path = _server_action_step(spec, action) + taskview.run_steps(self.stdscr, f"{action.capitalize()} " + f"{spec.name} server", [step], + wait_on_finish=False) + # Re-check the server instead of trusting the step's exit code + # (cancel and failure both come back non-zero): did the toggle take? + now_running = common.server_running(spec.url) + if action == "start" and not now_running: + tui.flash(self.stdscr, f"Could not start the {spec.name} " + f"server. See the log: {log_path}", "err") + elif action == "stop" and now_running: + tui.flash(self.stdscr, f"Could not stop the {spec.name} " + f"server. See the log: {log_path}", "err") + return tui.Wizard.BACK + + +class _TeeWriter: + """A file-like that mirrors writes to a log file and an inner stream. + + Used to capture the server module's plain-console output (the task view + already redirects stdout to its line-writer) into a persistent log file + under ``servers.LOG_DIR`` without losing the on-screen log tail. + """ + + def __init__(self, logf, inner): + self._logf = logf + self._inner = inner + + def write(self, text): + if not text: + return 0 + try: + self._logf.write(text) + except OSError: + pass + try: + self._inner.write(text) + except OSError: + pass + return len(text) + + def flush(self): + try: + self._logf.flush() + except OSError: + pass + try: + self._inner.flush() + except OSError: + pass + + +def _server_action_step(spec, action: str): + """Build a task step that starts/stops SPEC's server, logged to a file. + + ACTION is "start" or "stop". The step runs inside the task view (no + console drop): the server module's output is tee'd to + ``/-.log`` and to the view's log tail. + Returns ``(TaskStep, log_path)`` so the caller can point the user at the + file on failure. + """ + log_path = servers.LOG_DIR / f"{spec.name}-{action}.log" + title = (f"Start {spec.name} server" if action == "start" + else f"Stop {spec.name} server") + + def work(emit, cancel): + servers.LOG_DIR.mkdir(parents=True, exist_ok=True) + inner = sys.stdout # the task view's line-writer, when run in TUI + with log_path.open("w", encoding="utf-8") as logf, \ + contextlib.redirect_stdout(_TeeWriter(logf, inner)): if action == "start": - servers.start(spec) + ok = servers.start(spec, cancel=cancel) else: - servers.stop(spec.name) - return tui.Wizard.BACK + ok = servers.stop(spec.name) + return 0 if ok else 1 + + return taskview.TaskStep(title, work), log_path def _installable(info, by_key: dict) -> bool: diff --git a/app/ui/taskview.py b/app/ui/taskview.py index 7f8134b..ea72a86 100644 --- a/app/ui/taskview.py +++ b/app/ui/taskview.py @@ -143,13 +143,17 @@ def _lane_step_mark(current: Optional[int], return "[ ]", "dim" -def run_steps(scr, title: str, steps: List[TaskStep]) -> int: +def run_steps(scr, title: str, steps: List[TaskStep], + wait_on_finish: bool = True) -> int: """Run STEPS in order inside the curses screen; return the first bad rc. Returns 0 when every step succeeded, otherwise the first non-zero exit - code (a cancelled run returns a non-zero code too). + code (a cancelled run returns a non-zero code too). With WAIT_ON_FINISH + False the view returns to the caller as soon as the run reaches a + terminal phase instead of waiting for a key press (used by the hub's + start/stop actions, which land straight back on the menu). """ - view = TaskView(scr, title, steps) + view = TaskView(scr, title, steps, wait_on_finish=wait_on_finish) return view.run() @@ -192,12 +196,14 @@ class TaskView: """Draws and drives one list of setup steps; see the module docstring.""" def __init__(self, scr, title: str, steps: List[TaskStep], - clock: Callable[[], float] = time.time): + clock: Callable[[], float] = time.time, + wait_on_finish: bool = True): import curses self.curses = curses self.scr = scr self.title = title self.steps = steps + self.wait_on_finish = wait_on_finish self.theme = tui._ensure_theme(curses) self._clock = clock # -- state ----------------------------------------------------- @@ -330,6 +336,8 @@ class TaskView: while True: self._drain() self.render() + if self.phase in _TERMINAL and not self.wait_on_finish: + return self._result_rc() key = self._get_key() if key is None: continue @@ -458,14 +466,15 @@ class TaskView: break # -- footer ---------------------------------------------------- + suffix = "" if not self.wait_on_finish else " — press any key to return" if self.phase == "done": - footer = "completed — press any key to return" + footer = "completed" + suffix kind = "ok" elif self.phase == "cancelled": - footer = "cancelled — press any key to return" + footer = "cancelled" + suffix kind = "warn" elif self.phase == "error": - footer = "finished with errors — press any key to return" + footer = "finished with errors" + suffix kind = "err" elif self.cancelling: footer = "cancelling..." diff --git a/app/ui/tui.py b/app/ui/tui.py index 0d47863..34c9a3e 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -449,6 +449,9 @@ class Frame: selected = logical == self.cursor and row["selectable"] if selected: _addstr(scr, y, inner_x, " " * inner_w, theme["bar"]) + if row["align"] == "left": + _addch(scr, y, inner_x + self.LIST_MARGIN - 1, + self.curses.ACS_RARROW, theme["bar"]) if row["segments"] is not None: self._draw_segments_row(y, row, inner_x, inner_w, selected) else: -- cgit v1.2.3