From 8c9a782dfe94525dc5f0893c98fd19543648264b Mon Sep 17 00:00:00 2001 From: historia Date: Tue, 25 Aug 2026 19:03:09 -0400 Subject: fix: tui errors wait for getch() --- app/backends/audiocpp.py | 55 ++++++++++++++----------------------- app/backends/faster.py | 16 +++++------ app/backends/qwen.py | 14 ---------- app/tests/test_backends_audiocpp.py | 39 ++++++++------------------ app/tests/test_hub.py | 34 +++++++++++++++++++++++ app/tests/test_runview.py | 8 ++++++ app/tests/test_taskview.py | 50 +++++++++++++++++++++++++++++++++ app/tests/test_tui.py | 21 ++++++++++++++ app/ui/hub.py | 26 +++++++++++++----- app/ui/runview.py | 3 ++ app/ui/taskview.py | 6 ++++ app/ui/tui.py | 32 +++++++++++++-------- 12 files changed, 202 insertions(+), 102 deletions(-) diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py index 31aa01d..f6121ea 100755 --- a/app/backends/audiocpp.py +++ b/app/backends/audiocpp.py @@ -1987,39 +1987,28 @@ def _build_audiocpp_tui(emit, cancel, argv: List[str], command: str, return rc -def _print_launch_hint(audiocpp_dir: Path, output_path: Path, - pending_build: bool = False) -> None: - """Print the exact command to start the server (or build guidance). - - The command is prefixed with ``cd &&`` because the server - discovers model_specs/.json relative to its working directory. - PENDING_BUILD is True when the server is still building in a parallel - lane; instead of "build it first" remediation the hint then names the - command to run once that build finishes. +def _print_launch_hint(audiocpp_dir: Path, output_path: Path) -> None: + """Print remediation when audiocpp_server is missing (troubleshooting). + + The hub starts and stops the server itself, so a working install gets + no manual launch instructions. When no binary was built, though, the + user needs to know how to build and run it by hand. The commands are + prefixed with ``cd &&`` because the server discovers + model_specs/.json relative to its working directory. """ - binary = find_audiocpp_server_bin(audiocpp_dir) - print() - if binary is not None: - print("Start the server with:") - print(f" cd {audiocpp_dir} && {binary} --config {output_path}") - elif pending_build: - print("The server is still building in the other panel — once it " - "finishes, start it with:") - print(f" cd {audiocpp_dir} && ./build/--release" - f"/bin/audiocpp_server --config {output_path}") - else: - print("[INFO] audiocpp_server binary not found. Build it first, e.g.:") - script = find_build_script(audiocpp_dir) - if script is not None: - print(f" sh {script} --backend " - "--target audiocpp_server --deployment-build") - print(f" then run: cd {audiocpp_dir} && ./build/-" - f"-release/bin/audiocpp_server --config {output_path}") + if find_audiocpp_server_bin(audiocpp_dir) is not None: + return + print("\n[INFO] audiocpp_server binary not found. Build it first, e.g.:") + script = find_build_script(audiocpp_dir) + if script is not None: + print(f" sh {script} --backend " + "--target audiocpp_server --deployment-build") + print(f" then run: cd {audiocpp_dir} && ./build/-" + f"-release/bin/audiocpp_server --config {output_path}") def _execute_lanes(settings: dict, - args: argparse.Namespace, - parallel: bool = False) -> List[taskview.TaskLane]: + args: argparse.Namespace) -> List[taskview.TaskLane]: """Build the ordered setup steps for the in-TUI task view, per lane. The same work ``_execute`` runs on the console, split into two lanes so @@ -2031,8 +2020,7 @@ def _execute_lanes(settings: dict, dict scoped to the models lane. Each step's ``work(emit, cancel)`` returns its exit code; subprocess steps stream through EMIT and abort on CANCEL, while print()-based steps are captured by the view's stdout - routing. PARALLEL marks the launch hint as concurrent-with-build so it - does not claim the binary is missing while the build is still running. + routing. """ audiocpp_dir = settings["audiocpp_dir"] state: dict = {} @@ -2102,8 +2090,7 @@ def _execute_lanes(settings: dict, def install(emit, cancel): _install_models(audiocpp_dir, settings["install_guidance"], settings["download"], emit=emit, cancel=cancel) - _print_launch_hint(audiocpp_dir, settings["output_path"], - pending_build=bool(build and parallel)) + _print_launch_hint(audiocpp_dir, settings["output_path"]) return 0 install_title = "Download models" if settings.get("download") \ else "Print model install commands" @@ -2163,7 +2150,7 @@ def setup_screen(stdscr) -> int: if settings is None: return 1 return taskview.run_lanes(stdscr, "Setting up audio.cpp", - _execute_lanes(settings, args, parallel=True)) + _execute_lanes(settings, args)) def build_screen(stdscr) -> int: diff --git a/app/backends/faster.py b/app/backends/faster.py index 8c64183..7ac4dce 100755 --- a/app/backends/faster.py +++ b/app/backends/faster.py @@ -428,15 +428,15 @@ def _execute(settings: dict) -> int: def _print_launch_hint(voices_path: Path, port: int) -> None: - print() + """Remediation only (troubleshooting): what's missing when not cloned. + + The hub starts and stops the server itself, so a working install gets + no manual launch instructions. + """ if _is_cloned(): - py = envs.env_python() - print("Start the server with (or use the hub's 'Server' menu):") - print(f" {py} {_checkout()}/examples/openai_server.py " - f"--voices {voices_path} --port {port}") - else: - print("[INFO] Clone faster-qwen3-tts to get examples/openai_server.py,") - print(f" then run it with --voices {voices_path} --port {port}") + return + print("[INFO] Clone faster-qwen3-tts to get examples/openai_server.py,") + print(f" then run it with --voices {voices_path} --port {port}") def setup_screen(stdscr) -> int: diff --git a/app/backends/qwen.py b/app/backends/qwen.py index c6a11bf..cc84f8e 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -189,8 +189,6 @@ def _execute_steps(settings: dict) -> List[taskview.TaskStep]: else: print("[WARNING] Could not update SPEAKER; edit " "app/converter/config.py by hand") - - _print_launch_hint(settings["custom_port"], settings["clone_port"]) return 0 steps.append(taskview.TaskStep("Sync config & ports", sync)) @@ -202,18 +200,6 @@ def _execute(settings: dict) -> int: return taskview.run_steps_inline(_execute_steps(settings)) -def _print_launch_hint(custom_port: int, clone_port: int) -> None: - demo = envs.env_script("qwen-tts-demo") - print() - print("Start the servers (in separate terminals), or use the hub's") - print("'Server' menu / let a conversion start one automatically:") - print(f" {demo} {QWEN_CUSTOMVOICE_MODEL} --ip 127.0.0.1 " - f"--port {custom_port}") - print(f" {demo} {QWEN_BASE_MODEL} --ip 127.0.0.1 " - f"--port {clone_port}") - print("Then run: python audiobook.py --backend qwen") - - def setup_screen(stdscr) -> int: """Run the setup wizard on an existing curses screen (the hub's). diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 665dbf0..3063e99 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -1895,7 +1895,6 @@ class SetupScreenTests(unittest.TestCase): self.assertEqual(rc, 0) mk_lanes.assert_called_once() self.assertIs(mk_lanes.call_args[0][0], settings) - self.assertTrue(mk_lanes.call_args[1]["parallel"]) mk_run.assert_called_once() self.assertEqual(mk_run.call_args[0][2], lanes) @@ -1956,48 +1955,34 @@ class ExecuteLanesTests(unittest.TestCase): "Write server.json & sync config", "Download models"]) - def test_download_step_prints_the_parallel_launch_hint(self): - args = make_server.build_parser().parse_args([]) - lanes = make_server._execute_lanes(self._settings(), args, - parallel=True) - install_step = lanes[1].steps[2] - with patch.object(make_server, "_install_models") as mk_install, \ - patch.object(make_server, "_print_launch_hint") as mk_hint: - install_step.work(lambda line: None, threading.Event()) - mk_hint.assert_called_once() - self.assertTrue(mk_hint.call_args[1]["pending_build"]) - - def test_download_step_console_hint_is_not_pending(self): + def test_download_step_prints_the_launch_hint(self): args = make_server.build_parser().parse_args([]) lanes = make_server._execute_lanes(self._settings(), args) install_step = lanes[1].steps[2] - with patch.object(make_server, "_install_models") as mk_install, \ + with patch.object(make_server, "_install_models"), \ patch.object(make_server, "_print_launch_hint") as mk_hint: install_step.work(lambda line: None, threading.Event()) - mk_hint.assert_called_once() - self.assertFalse(mk_hint.call_args[1]["pending_build"]) + mk_hint.assert_called_once_with(Path("/x"), Path("/x/server.json")) class LaunchHintTests(unittest.TestCase): - """_print_launch_hint: exact command vs. the pending-build message.""" + """_print_launch_hint: silent when built, remediation when not.""" - def _capture(self, audiocpp_dir, output_path, pending_build=False): + def _capture(self, audiocpp_dir, output_path, binary=None): buf = io.StringIO() with redirect_stdout(buf), \ patch.object(make_server, "find_audiocpp_server_bin", - return_value=None): - make_server._print_launch_hint(audiocpp_dir, output_path, - pending_build=pending_build) + return_value=binary): + make_server._print_launch_hint(audiocpp_dir, output_path) return buf.getvalue() - def test_pending_build_names_the_post_build_command(self): + def test_built_server_prints_nothing(self): + # The hub starts/stops the server itself; no manual instructions. out = self._capture(Path("/tmp/acpp"), Path("/tmp/acpp/server.json"), - pending_build=True) - self.assertIn("still building", out) - self.assertNotIn("Build it first", out) - self.assertIn("audiocpp_server --config /tmp/acpp/server.json", out) + binary=Path("/tmp/acpp/build/x/bin/audiocpp_server")) + self.assertEqual(out, "") def test_missing_binary_gives_build_remediation(self): out = self._capture(Path("/tmp/acpp"), Path("/tmp/acpp/server.json")) self.assertIn("Build it first", out) - self.assertNotIn("still building", out) + self.assertNotIn("Start the server with:", out) diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index ec079f5..3feaa05 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -1730,6 +1730,40 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): self.assertIs(result, tui.Wizard.BACK) self.assertEqual(flashes, ["kaboom"]) + def test_screen_install_runs_setup_inline_then_goes_back(self): + # No stack frame for the setup: BACK pops past the picker straight + # to the Configure menu instead of re-showing "Install Backend". + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) + with patch.object(hub._Hub, "_pick_backend", return_value=info), \ + patch.object(info, "setup_screen") as mk_setup: + result = hub._Hub(None).screen_install() + mk_setup.assert_called_once_with(None) + self.assertIs(result, tui.Wizard.BACK) + + def test_screen_install_esc_on_picker_goes_back_without_setup(self): + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) + with patch.object(hub._Hub, "_pick_backend", return_value=None), \ + patch.object(info, "setup_screen") as mk_setup: + result = hub._Hub(None).screen_install() + mk_setup.assert_not_called() + self.assertIs(result, tui.Wizard.BACK) + + def test_screen_install_flashes_on_crash_and_still_goes_back(self): + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) + flashes = [] + + def boom(scr): + raise RuntimeError("kaboom") + + with patch.object(hub._Hub, "_pick_backend", return_value=info), \ + patch.object(info, "setup_screen", boom), \ + patch.object(hub.tui, "flash", + lambda scr, text, kind="warn": + flashes.append(text)): + result = hub._Hub(None).screen_install() + self.assertIs(result, tui.Wizard.BACK) + self.assertEqual(flashes, ["kaboom"]) + def test_screen_uninstall_runs_in_task_view_and_goes_back(self): info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) with patch.object(hub._Hub, "_pick_backend", return_value=info), \ diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py index df697c8..7006427 100644 --- a/app/tests/test_runview.py +++ b/app/tests/test_runview.py @@ -191,6 +191,14 @@ class RunLoopTests(_FakeTui, unittest.TestCase): # (started_server is False). self.assertEqual(view.phase, "done") + def test_run_leaves_the_screen_blocking_again(self): + # The timed redraw cadence must not leak into the hub: blocking + # input is restored so later dialogs (tui.flash) wait for keys. + view, screen = self.make_view(keys=[ord("x")]) + view._queue.put({"kind": "done", "ok": 1, "total": 1}) + view.run() + self.assertEqual(screen.timeouts[-1], -1) + def test_esc_cancels_and_confirms_stop_server(self): confirm_answers = [True, True] # cancel? yes; stop server? yes with patch.object(runview.tui, "confirm", diff --git a/app/tests/test_taskview.py b/app/tests/test_taskview.py index 4b03b41..6c984ad 100644 --- a/app/tests/test_taskview.py +++ b/app/tests/test_taskview.py @@ -276,6 +276,42 @@ class NoWaitTests(_FakeTui, unittest.TestCase): self.assertNotIn("press any key", text) +class InputModeRestoreTests(_FakeTui, unittest.TestCase): + """run() must leave the screen blocking again when the view exits. + + The views drive their redraw loop with a timed getch; if that cadence + leaked into the hub, single-getch dialogs like tui.flash would dismiss + themselves after one timeout instead of waiting for a key. + """ + + def test_task_view_run_restores_blocking_getch(self): + def fake_worker_main(view): + view._queue.put({"kind": "step_start", "index": 0, + "title": "one"}) + view._queue.put({"kind": "step_done", "index": 0, "rc": 0}) + view._queue.put({"kind": "finish", "phase": "done", "rc": 0}) + + screen = FakeScreen(width=80, height=24) + with patch.object(taskview.TaskView, "_worker_main", + fake_worker_main): + view = taskview.TaskView(screen, "Setup", [_step("one")], + clock=lambda: 1000.0, + wait_on_finish=False) + view._worker = _SyncWorker(view._worker_main) + view.run() + self.assertEqual(screen.timeouts[-1], -1) + + def test_lanes_view_run_restores_blocking_getch(self): + screen = FakeScreen(keys=[27]) # any key leaves the finished view + lanes = [taskview.TaskLane("A", [_step("a")]), + taskview.TaskLane("B", [_step("b")])] + view = taskview.LanesView(screen, "Setup", lanes, + clock=lambda: 1000.0) + with patch.object(taskview.threading, "Thread", _SyncThread): + view.run() + self.assertEqual(screen.timeouts[-1], -1) + + class _SyncWorker: """A stand-in for threading.Thread that runs the target synchronously.""" @@ -286,6 +322,20 @@ class _SyncWorker: self._fn() +class _SyncThread: + """A threading.Thread stand-in that runs its target on start().""" + + def __init__(self, target=None, args=(), kwargs=None, daemon=None): + self._target = target + self._args = args + + def start(self): + self._target(*self._args) + + def join(self, timeout=None): + pass + + 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 543194e..1b7af86 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -88,6 +88,10 @@ class FakeScreen: self.height = height self.strings = [] # (y, x, text, attr) from addstr self.chars = [] # (y, x, ch, attr) from addch + self.timeouts = [] # ms values passed to timeout() + + def timeout(self, ms): + self.timeouts.append(ms) def getmaxyx(self): return self.height, self.width @@ -979,6 +983,23 @@ class FlashTests(TuiTestCase): self.assertEqual(attr, tui._THEME["warn"]) self.assert_inside_border(screen) + def test_timed_out_reads_are_ignored_until_a_real_key(self): + # A screen left in redraw-cadence mode feeds getch() -1s; the + # notice must still wait for an actual key press. + screen = FakeScreen(keys=[-1, -1, ord("x")]) + tui.flash(screen, "a notice", kind="err") + self.assertEqual(screen.keys, []) + + def test_ctrl_c_still_aborts(self): + screen = FakeScreen(keys=[-1, 3]) + with self.assertRaises(tui.WizardCancelled): + tui.flash(screen, "a notice") + + def test_frame_flash_ignores_timed_out_reads_too(self): + frame = tui.Frame(FakeScreen(keys=[-1, 10]), "Title", "footer") + frame.flash("status line notice", "err") + self.assertIsNone(frame.status) + class WizardTests(unittest.TestCase): """The tui.Wizard screen-stack driver: Esc steps back one screen.""" diff --git a/app/ui/hub.py b/app/ui/hub.py index d20b28e..c1f5dce 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -237,20 +237,32 @@ class _Hub: back to the menu that launched it. A crash flashes and does the same. """ def screen(): - try: - info.setup_screen(self.stdscr) - except tui.WizardCancelled: - pass - except Exception as exc: # noqa: BLE001 - keep the hub alive - tui.flash(self.stdscr, str(exc), "err") + self._run_setup(info) return tui.Wizard.BACK return screen + def _run_setup(self, info) -> None: + """Run one backend's setup wizard on this session (no stack frame).""" + try: + info.setup_screen(self.stdscr) + except tui.WizardCancelled: + pass + except Exception as exc: # noqa: BLE001 - keep the hub alive + tui.flash(self.stdscr, str(exc), "err") + def screen_install(self): + """Pick a backend to install and run its setup inline. + + The setup is not pushed as a stack frame: when it finishes this + screen returns BACK, popping straight past the picker to the + Configure menu, whose status table re-detects the new install. + Esc on the picker still pops back one screen normally. + """ info = self._pick_backend(installed_only=False) if info is None: return tui.Wizard.BACK - return self.screen_setup(info) + self._run_setup(info) + return tui.Wizard.BACK def screen_uninstall(self): """Pick a backend, confirm, then uninstall it inside the task view. diff --git a/app/ui/runview.py b/app/ui/runview.py index f016922..d49d949 100644 --- a/app/ui/runview.py +++ b/app/ui/runview.py @@ -301,6 +301,9 @@ class RunView: finally: self._monitor_stop.set() self._cancel.set() + # Leave the screen blocking again: the timed redraw getch must + # not make later hub dialogs (e.g. tui.flash) dismiss themselves. + self._blocking() def _get_key(self) -> Optional[int]: """One key from the screen (None on the redraw timeout).""" diff --git a/app/ui/taskview.py b/app/ui/taskview.py index c4fd35e..6708304 100644 --- a/app/ui/taskview.py +++ b/app/ui/taskview.py @@ -349,6 +349,9 @@ class TaskView: return self._result_rc() finally: self._cancel.set() + # Leave the screen blocking again: the timed redraw getch must + # not make later hub dialogs (e.g. tui.flash) dismiss themselves. + self._blocking() def _result_rc(self) -> int: """The exit code for the whole run (cancelled counts as failure).""" @@ -860,6 +863,9 @@ class LanesView: return self._result_rc() finally: self._cancel.set() + # Leave the screen blocking again: the timed redraw getch + # must not make later hub dialogs dismiss themselves. + self._blocking() finally: sys.stdout, sys.stderr = saved_out, saved_err diff --git a/app/ui/tui.py b/app/ui/tui.py index d675946..b1b18f1 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -123,17 +123,22 @@ def flash(scr, text: str, kind: str = "warn") -> None: Used by the hub for "not set up yet"-style messages. KIND is a theme key (warn/err/ok/info). The notice itself is the dialog's only content (no "Notice" heading); Esc dismisses it (it does not abort). + A timed-out getch (-1; the screen may still be in a redraw cadence) + is ignored so the notice really waits for a key. """ frame = Frame(scr, "", "Press any key to continue Esc = back") frame.mark(text, frame.theme.get(kind, frame.theme["body"])) frame.cursor = None frame.draw() - try: - key = scr.getch() - except KeyboardInterrupt: - raise WizardCancelled() from None - if key == 3: # Ctrl-C still aborts - raise WizardCancelled() + while True: + try: + key = scr.getch() + except KeyboardInterrupt: + raise WizardCancelled() from None + if key == 3: # Ctrl-C still aborts + raise WizardCancelled() + if key != -1: + return # On screens without typed text, Esc and 'q' mean the same thing: go @@ -581,12 +586,15 @@ class Frame: """Show TEXT on the status line until any key is pressed.""" self.status = (text, kind) self.draw() - try: - key = self.scr.getch() - if key == 3: # Ctrl-C still aborts - raise WizardCancelled() - except KeyboardInterrupt: - raise WizardCancelled() from None + while True: + try: + key = self.scr.getch() + if key == 3: # Ctrl-C still aborts + raise WizardCancelled() + except KeyboardInterrupt: + raise WizardCancelled() from None + if key != -1: + break self.status = None def edit_status(self, prompt: str = "") -> Optional[str]: -- cgit v1.2.3