From fe4b2b9eb7fb8aac81f65630720c9079d0a3121a Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 23:49:34 -0400 Subject: feat: user-friendly menu gating, clearer install/configure path for backends --- app/ui/hub.py | 165 +++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 122 insertions(+), 43 deletions(-) (limited to 'app/ui/hub.py') diff --git a/app/ui/hub.py b/app/ui/hub.py index d71fa4c..247dd49 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -10,8 +10,10 @@ The entire hub runs in one curses session, driven by a single ``tui.Wizard`` stack of screens (the ``_Hub`` class below). Every menu/action is a screen that returns the next screen, ``Wizard.BACK`` (Esc/q) to pop one screen, or None to quit. Backend setup wizards and the conversion run view run as -opaque leaf screens on this same session (console tails under -``tui.suspend``); a leaf screen finishes by returning ``Wizard.BACK``, so +opaque leaf screens on this same session (the wizards' long setup tails and +model downloads run inside the ``ui.taskview`` task view, and only the quick +uninstall/start/stop actions use ``tui.suspend``); a leaf screen finishes by +returning ``Wizard.BACK``, so the stack lands back on the menu that launched it. Esc therefore steps back exactly one screen everywhere — on the main menu (an empty stack) it quits. 'q' mirrors Esc on every screen that has no typed text. @@ -56,7 +58,7 @@ from converter.tts import ( BACKEND_QWEN, normalize_language, ) -from ui import runview, tui +from ui import runview, taskview, tui _CANCEL = object() # sentinel: a convert preflight confirm backed out @@ -74,6 +76,12 @@ def run() -> int: return 0 except KeyboardInterrupt: return 130 + finally: + # The curses session is over and the terminal is restored: surface + # anything setup steps queued for the console (e.g. a failed audio.cpp + # build's copy-pastable command and build log path). + for notice in common.drain_post_tui_notices(): + print(notice) return 0 @@ -134,11 +142,15 @@ class _Hub: def screen_configure(self): """One flat menu of backend setup/configure/cleanup actions. - Options are populated from the detected statuses: install (any - uninstalled backend), configure each installed backend, - download/delete audio.cpp models (when a server.json references - models on/off disk), and uninstall. Selecting one pushes the next - screen; Esc pops back to the main menu. + The audio.cpp "next step" — build its server (when a checkout has + no binary) or download its missing models (only once built, so + build > configure > download — Build and Download never appear + together) — heads the menu with a yellow ``[recommended]`` tag, + separated from the rest by a blank line. The remaining options are + populated from the detected statuses: configure each installed + backend, install (backends with nothing on disk), and uninstall. + Selecting one pushes the next screen; Esc pops back to the main + menu. """ while True: statuses = detect_all() @@ -146,25 +158,42 @@ class _Hub: installed = [info for info in REGISTRY if by_key.get(info.key) is not None and by_key[info.key].installed] - options = [(f"Configure {info.label}", ("configure", info.key)) - for info in installed] - if any(info.key not in by_key or not by_key[info.key].installed - for info in REGISTRY): - options.append(("Install Backend", "install")) audiocpp_status = by_key.get("audiocpp") missing = [] - if audiocpp_status is not None and audiocpp_status.installed: + needs_build = False + if audiocpp_status is not None: checkout = audiocpp_backend.find_local_checkout() - server_json = checkout / "server.json" if checkout else None - if server_json is not None and server_json.exists(): - missing = audiocpp_backend.missing_model_entries( - server_json) - if missing: + if checkout is not None: + built = audiocpp_backend.find_audiocpp_server_bin( + checkout) is not None + if not built: + needs_build = True + server_json = checkout / "server.json" + # Models can only be downloaded once the server binary + # exists (build > configure > download), so Build and + # Download never appear together. + if built and audiocpp_status.configured \ + and server_json.exists(): + missing = audiocpp_backend.missing_model_entries( + server_json) + + options = [] + if needs_build: + options.append(("Build audio.cpp server", "build_audiocpp", + ("[recommended]", "warn"))) + elif missing: options.append(("Download Missing Models (audio.cpp)", - "download_models")) - - if installed: + "download_models", + ("[recommended]", "warn"))) + if needs_build or missing: + options.append(tui.MENU_SEPARATOR) + + options += [(f"Configure {info.label}", ("configure", info.key)) + for info in installed] + if any(_installable(info, by_key) for info in REGISTRY): + options.append(("Install Backend", "install")) + if any(_uninstallable(info, by_key) for info in REGISTRY): options.append(("Uninstall Backend", "uninstall")) choice = tui.menu( @@ -183,6 +212,9 @@ class _Hub: if choice == "download_models": _download_models_action(self.stdscr) continue # an inline action: re-show this same menu + if choice == "build_audiocpp": + audiocpp_backend.build_screen(self.stdscr) + continue # an inline action: re-show this same menu _kind, key = choice info = get(key) if info is None: @@ -223,20 +255,22 @@ class _Hub: def _pick_backend(self, installed_only: bool): """Pick a backend for the Install/Uninstall actions. - With INSTALLED_ONLY False every backend is listed (the install - list); with it True only the currently-installed ones are (the - uninstall list). Returns a registry entry, or None to go back. + With INSTALLED_ONLY False every backend with nothing on disk yet is + listed (the install list — audio.cpp only without a checkout, since + a downloaded-but-unbuilt checkout is past install); with it True the + ones with something on disk to remove are (the uninstall list — + including a downloaded-but-unbuilt audio.cpp checkout, which + ``uninstall`` deletes whole). Returns a registry entry, or None to + go back. """ statuses = detect_all() by_key = {st.key: st for st in statuses} if installed_only: candidates = [info for info in REGISTRY - if by_key.get(info.key) is not None - and by_key[info.key].installed] + if _uninstallable(info, by_key)] else: candidates = [info for info in REGISTRY - if by_key.get(info.key) is None - or not by_key[info.key].installed] + if _installable(info, by_key)] if not candidates: tui.flash(self.stdscr, "No backends to list here.") return None @@ -384,15 +418,45 @@ class _Hub: return tui.Wizard.BACK +def _installable(info, by_key: dict) -> bool: + """True when INFO has nothing on disk yet — an install-entry candidate. + + audio.cpp is installable only without a checkout: a downloaded-but-unbuilt + checkout is already past the install step (its next action is the hub's + Build entry), so listing it under "Install Backend" would duplicate that + and suggest re-running setup from scratch. The other backends are + installable while not installed. + """ + if info.key == "audiocpp": + return audiocpp_backend.find_local_checkout() is None + status = by_key.get(info.key) + return status is None or not status.installed + + +def _uninstallable(info, by_key: dict) -> bool: + """True when INFO has something on disk that uninstall removes. + + audio.cpp's ``installed`` flag means *built*, so a downloaded-but-unbuilt + checkout would otherwise miss the Uninstall menu — but its checkout + (binary, models, server.json) lives on disk and ``uninstall()`` removes + it, so it counts too. The other backends' ``installed`` already covers + everything their uninstaller touches. + """ + if info.key == "audiocpp": + return audiocpp_backend.find_local_checkout() is not None + status = by_key.get(info.key) + return status is not None and status.installed + + def _download_models_action(stdscr) -> None: """Run the "Download Missing Models (audio.cpp)" action inside the TUI. - Computes the missing models; when they map to install commands it - suspends curses to stream the downloads, then flashes a result — instead - of silently returning to the main menu. When the checkout/server.json is - missing, nothing is missing, or the models do not map to an install - command, it flashes an explanatory notice (the latter explaining how to - install each model by hand). + Computes the missing models; when they map to install commands it runs + the downloads in the task view (with real byte progress and cancellation) + and flashes a result — instead of dropping to the console. When the + checkout/server.json is missing, nothing is missing, or the models do not + map to an install command, it flashes an explanatory notice (the latter + explaining how to install each model by hand). """ checkout = audiocpp_backend.find_local_checkout() if checkout is None: @@ -415,10 +479,16 @@ def _download_models_action(stdscr) -> None: tui.flash(stdscr, audiocpp_backend.hand_install_guidance( checkout, missing), "err") return - with tui.suspend(stdscr): - audiocpp_backend.install_models(checkout, guidance) - tui.flash(stdscr, "Model download finished. See the output above for " - "any warnings.", "ok") + + def run(emit, cancel): + audiocpp_backend.install_models(checkout, guidance, + emit=emit, cancel=cancel) + return 0 + + taskview.run_steps(stdscr, "Download models", + [taskview.TaskStep("Download missing models", run)]) + tui.flash(stdscr, "Model download finished. Any warnings were shown in " + "the log.", "ok") def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: @@ -428,10 +498,13 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: server this tool started (``status.managed``) — or remotely — a server found by probing its remote URL (``status.remote``); the text names which, e.g. "running [local]", "running [remote]", or - "running [local, remote]". Otherwise 'installed' (orange/warn) when the - backend is present on disk, or 'unavailable' (red/err); a backend that is - neither installed nor running is unusable, so its name is dimmed - (NAME_KIND). A multi-model backend (qwen) also names which models + "running [local, remote]". Otherwise a backend set up only part-way + (``status.partial``) shows that label verbatim (amber), e.g. audio.cpp's + "downloaded (not built)" or "built (not configured)"; 'installed' + (orange/warn) when the backend is present on disk; or 'unavailable' + (red/err). A backend that is neither installed nor running is unusable, + so its name is dimmed (NAME_KIND). A multi-model backend (qwen) also + names which models answered in parentheses, e.g. "running [local, remote] (Base, CustomVoice)". CURSES has no true orange, so the theme's yellow 'warn' is used; it renders amber/orange on most terminals. @@ -448,6 +521,12 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: if status.running_models: text += " (" + ", ".join(status.running_models) + ")" return (text, "ok", "body") + if status is not None and status.partial: + # Part-way set up (audio.cpp: "downloaded (not built)" / + # "built (not configured)"): amber text, name dimmed while the + # backend is still unusable. + name_kind = "dim" if not status.installed else "body" + return (status.partial, "warn", name_kind) if status is not None and status.installed: if status.models_missing and not status.running: return ("installed (models missing)", "warn", "body") -- cgit v1.2.3