aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-25 18:10:08 -0400
committerhistoria <historiavg@proton.me>2026-08-25 18:10:08 -0400
commit4b49797b4d57c2d2cf63472636622a7a6280a38e (patch)
treee3687c734de5d6159cdad288e025057ed6cd3a16
parentbcac6c42eaf9e004716d72960bddefb1db68a93c (diff)
downloadtts-audiobook-generator-4b49797b4d57c2d2cf63472636622a7a6280a38e.tar.gz
feat: no console drop when uninstalling backends
-rw-r--r--README.md2
-rw-r--r--app/backends/__init__.py7
-rwxr-xr-xapp/backends/audiocpp.py15
-rw-r--r--app/backends/common.py23
-rw-r--r--app/backends/envs.py9
-rwxr-xr-xapp/backends/faster.py17
-rw-r--r--app/backends/qwen.py16
-rw-r--r--app/tests/test_backends.py37
-rw-r--r--app/tests/test_backends_audiocpp.py29
-rw-r--r--app/tests/test_backends_envs.py45
-rw-r--r--app/tests/test_backends_faster.py58
-rw-r--r--app/tests/test_hub.py79
-rw-r--r--app/ui/hub.py42
13 files changed, 348 insertions, 31 deletions
diff --git a/README.md b/README.md
index 3ed84e8..5844321 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@ cd tts-audiobook-generator
python audiobook.py
```
-4. When the TUI comes up, go to `Configure Backends > Install Backend`. Install `audio.cpp`, which supports numerous TTS models. It will automatically be cloned and built in the venv (this will take a while) — the clone runs in the TUI, and after you pick your models the build and the model downloads run **simultaneously** in a split view (with a status and progress bar for each), so you're never dropped to the console. If a download fails or is interrupted, `Configure Backends > Download Missing Models` re-runs it.
+4. When the TUI comes up, go to `Configure Backends > Install Backend`. Install `audio.cpp`, which supports numerous TTS models. It will automatically be cloned and built in the venv (this will take a while) — the clone runs in the TUI, and after you pick your models the build and the model downloads run **simultaneously** in a split view (with a status and progress bar for each), so you're never dropped to the console. Uninstalling a backend (`Configure Backends > Uninstall Backend`) asks for confirmation and likewise runs inside the TUI. If a download fails or is interrupted, `Configure Backends > Download Missing Models` re-runs it.
5. Choose TTS models to install. `Qwen3-TTS` is a popular. Pick the `Base` model if you're cloning voices or `CustomVoice` for built-in TTS.
diff --git a/app/backends/__init__.py b/app/backends/__init__.py
index 83f9866..63f0709 100644
--- a/app/backends/__init__.py
+++ b/app/backends/__init__.py
@@ -147,13 +147,16 @@ class BackendInfo:
SETUP_SCREEN runs the setup wizard on an already-open curses screen
(the hub's), returning 0 on completion and non-zero when aborted; the
- hub calls it as one screen of its own ``tui.Wizard`` stack.
+ hub calls it as one screen of its own ``tui.Wizard`` stack. UNINSTALL
+ removes the backend (stops its servers, pip-uninstalls, deletes its
+ files); the hub runs it inside the task view, calling it with optional
+ ``emit``/``cancel`` keywords (cancel honored between phases only).
"""
key: str
label: str
detect: Callable[[], BackendStatus]
setup_screen: Callable[[object], int]
- uninstall: Callable[[], int] = lambda: 0
+ uninstall: Callable[..., int] = lambda *args, **kwargs: 0
REGISTRY: List[BackendInfo] = []
diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py
index 0feb480..31aa01d 100755
--- a/app/backends/audiocpp.py
+++ b/app/backends/audiocpp.py
@@ -1650,16 +1650,25 @@ def delete_model_files(server_json: Path, entries: List[dict]) -> int:
return removed
-def uninstall() -> int:
+def uninstall(*, emit=None, cancel=None) -> int:
"""Remove the audio.cpp backend entirely: stop its server, delete the checkout.
The checkout (``app/audio.cpp``, or wherever ``find_local_checkout``
resolves it) holds the built binary, the downloaded models, and the
server.json, so removing the directory uninstalls the backend. A running
- server this tool started is stopped first (best-effort). Returns the exit
- code.
+ server this tool started is stopped first (best-effort).
+
+ EMIT is accepted for registry symmetry with the other backends but is
+ unused here — this uninstall has no subprocess phase, and its prints are
+ captured by the task view when run in the TUI. CANCEL is a
+ ``threading.Event`` honored between phases only (after the server has
+ been stopped, before the checkout is deleted), so a started phase always
+ completes and the uninstall never tears halfway. Returns the exit code
+ (130 when cancelled before a remaining phase).
"""
servers.stop("audiocpp")
+ if common.cancel_requested(cancel):
+ return 130
checkout = find_local_checkout()
if checkout is None:
print("[INFO] No audio.cpp checkout to remove.")
diff --git a/app/backends/common.py b/app/backends/common.py
index cb573c3..10b4ccf 100644
--- a/app/backends/common.py
+++ b/app/backends/common.py
@@ -71,6 +71,18 @@ def drain_post_tui_notices() -> List[str]:
return notices
+def cancel_requested(cancel) -> bool:
+ """True when CANCEL (a ``threading.Event``) is given and set.
+
+ Shared guard for the multi-phase uninstall actions: cancellation is
+ honored only between phases (stop servers / pip / delete files), so a
+ phase that already started always runs to completion and an uninstall
+ never tears halfway. Callers return 130 when this fires before a
+ pending phase.
+ """
+ return cancel is not None and cancel.is_set()
+
+
def normalize_dir_arg(value: str) -> Path:
"""Normalize a user-supplied path argument.
@@ -301,8 +313,7 @@ def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None,
"""Run a subprocess, streaming output to the console or to EMIT.
With EMIT None the child inherits the real terminal and its output
- appears normally (used by the non-interactive CLI paths and the quick
- ``tui.suspend`` actions like uninstall). With EMIT given (a
+ appears normally (the non-interactive CLI paths). With EMIT given (a
``callable(str)``) the child's stdout/stderr are merged, read line by
line (splitting on both ``\\n`` and ``\\r`` so carriage-return progress
updates like git's or tqdm's surface as lines), and each line is passed
@@ -459,11 +470,13 @@ def pip_install(packages: List[str]) -> int:
return envs.pip_install(packages)
-def pip_uninstall(packages: List[str]) -> int:
+def pip_uninstall(packages: List[str], *, emit=None) -> int:
"""pip uninstall PACKAGES from the managed venv. Returns exit code.
Delegates to ``backends.envs.pip_uninstall`` (local import to avoid a
- circular import). Used by the backends' ``uninstall`` action.
+ circular import). Used by the backends' ``uninstall`` action. With EMIT
+ given (the in-TUI task view) pip runs piped, streaming into EMIT, so
+ its output never touches the terminal behind curses.
"""
from backends import envs
- return envs.pip_uninstall(packages)
+ return envs.pip_uninstall(packages, emit=emit)
diff --git a/app/backends/envs.py b/app/backends/envs.py
index 7b3c54b..cfeeec6 100644
--- a/app/backends/envs.py
+++ b/app/backends/envs.py
@@ -113,18 +113,21 @@ def pip_install(packages: List[str], *, emit=None, cancel=None) -> int:
return common.run_console_subprocess(argv, emit=emit, cancel=cancel)
-def pip_uninstall(packages: List[str]) -> int:
+def pip_uninstall(packages: List[str], *, emit=None) -> int:
"""pip uninstall PACKAGES from the venv. Returns pip's exit code.
Used by the backends' ``uninstall`` action to remove pip-installed TTS
packages from the managed environment. A missing env is a no-op (there
- is nothing to uninstall from), reported as success.
+ is nothing to uninstall from), reported as success. With EMIT given
+ (the in-TUI task view) pip runs with its output piped and streamed to
+ EMIT, so nothing writes to the terminal behind curses.
"""
if not env_exists():
return 0
print(f"[INFO] pip uninstall {' '.join(packages)} from {ENV_DIR}...")
return common.run_console_subprocess(
- [str(env_python()), "-m", "pip", "uninstall", "-y", *packages])
+ [str(env_python()), "-m", "pip", "uninstall", "-y", *packages],
+ emit=emit)
def module_available(module: str) -> bool:
diff --git a/app/backends/faster.py b/app/backends/faster.py
index 585e480..8c64183 100755
--- a/app/backends/faster.py
+++ b/app/backends/faster.py
@@ -591,21 +591,32 @@ def _detect_remote(managed: bool = False):
return False, {}
-def uninstall() -> int:
+def uninstall(*, emit=None, cancel=None) -> int:
"""Remove the faster-qwen3-tts backend entirely.
Uninstalls the pip package (``faster-qwen3-tts``) from the managed venv
and deletes the cloned checkout (``app/faster-qwen3-tts``, which holds
examples/openai_server.py and voices.json). A running server this tool
- started is stopped first (best-effort). Returns the exit code.
+ started is stopped first (best-effort).
+
+ With EMIT given (the in-TUI task view) pip runs piped, streaming into
+ EMIT, so its output never touches the terminal behind curses. CANCEL is
+ a ``threading.Event`` honored between phases only (stop server / pip /
+ delete checkout) — a started phase always completes, so pip is never
+ killed mid-run. Returns the exit code (130 when cancelled before a
+ remaining phase).
"""
servers.stop("faster")
- rc = common.pip_uninstall(["faster-qwen3-tts"])
+ if common.cancel_requested(cancel):
+ return 130
+ rc = common.pip_uninstall(["faster-qwen3-tts"], emit=emit)
if rc != 0:
print("[WARNING] pip uninstall failed (exit "
f"{rc}); remove faster-qwen3-tts from the managed venv manually")
else:
print("[OK] faster-qwen3-tts removed.")
+ if common.cancel_requested(cancel):
+ return 130
checkout = _checkout()
if checkout.is_dir():
print(f"[INFO] Removing checkout {checkout}...")
diff --git a/app/backends/qwen.py b/app/backends/qwen.py
index be7ebfe..c6a11bf 100644
--- a/app/backends/qwen.py
+++ b/app/backends/qwen.py
@@ -354,17 +354,25 @@ def _detect_remote(managed: bool = False):
return remote_models, remote_urls
-def uninstall() -> int:
+def uninstall(*, emit=None, cancel=None) -> int:
"""Remove the qwen-tts backend entirely: stop its servers, pip uninstall.
qwen-tts is a pip package (``qwen_tts`` + the ``qwen-tts-demo`` script)
installed into the managed venv, so uninstalling it removes the backend.
- Any server this tool started is stopped first (best-effort). Returns the
- exit code.
+ Any server this tool started is stopped first (best-effort).
+
+ With EMIT given (the in-TUI task view) pip runs piped, streaming into
+ EMIT, so its output never touches the terminal behind curses. CANCEL is
+ a ``threading.Event`` honored between phases only (after the servers
+ have been stopped, before pip starts) — a started phase always completes,
+ so pip is never killed mid-run. Returns the exit code (130 when
+ cancelled before pip ran).
"""
servers.stop("qwen-custom")
servers.stop("qwen-clone")
- rc = common.pip_uninstall([QWEN_PIP_PKG])
+ if common.cancel_requested(cancel):
+ return 130
+ rc = common.pip_uninstall([QWEN_PIP_PKG], emit=emit)
if rc != 0:
print(f"[WARNING] pip uninstall failed (exit {rc}); remove "
f"{QWEN_PIP_PKG} from the managed venv manually")
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py
index 0d3be37..8ea6a3f 100644
--- a/app/tests/test_backends.py
+++ b/app/tests/test_backends.py
@@ -325,3 +325,40 @@ class QwenSetupScreenTests(unittest.TestCase):
self.assertIs(mk_steps.call_args[0][0], settings)
mk_run.assert_called_once()
self.assertEqual(mk_run.call_args[0][2], steps)
+
+
+class QwenUninstallTests(unittest.TestCase):
+ """qwen.uninstall: stop both servers, then pip-uninstall the package."""
+
+ def test_stops_servers_and_pips(self):
+ from backends import qwen
+ with patch.object(qwen.servers, "stop") as mk_stop, \
+ patch.object(qwen.common, "pip_uninstall",
+ return_value=0) as mk_pip:
+ rc = qwen.uninstall(emit="EMIT")
+ self.assertEqual(rc, 0)
+ self.assertEqual([c.args[0] for c in mk_stop.call_args_list],
+ ["qwen-custom", "qwen-clone"])
+ # The task view's emit is forwarded so pip never touches the terminal.
+ mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit="EMIT")
+
+ def test_cancel_before_pip_skips_uninstall(self):
+ import threading
+
+ from backends import qwen
+ cancel = threading.Event()
+ cancel.set()
+ with patch.object(qwen.servers, "stop") as mk_stop, \
+ patch.object(qwen.common, "pip_uninstall") as mk_pip:
+ rc = qwen.uninstall(cancel=cancel)
+ self.assertEqual(rc, 130)
+ self.assertEqual(mk_stop.call_count, 2)
+ mk_pip.assert_not_called()
+
+ def test_pip_failure_warns_but_still_succeeds(self):
+ from backends import qwen
+ with patch.object(qwen.servers, "stop"), \
+ patch.object(qwen.common, "pip_uninstall",
+ return_value=1):
+ rc = qwen.uninstall()
+ self.assertEqual(rc, 0)
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py
index e206ddd..665dbf0 100644
--- a/app/tests/test_backends_audiocpp.py
+++ b/app/tests/test_backends_audiocpp.py
@@ -1836,6 +1836,35 @@ class UninstallTests(unittest.TestCase):
self.assertEqual(rc, 0)
mk_stop.assert_called_once_with("audiocpp")
+ def test_accepts_task_view_kwargs_for_registry_symmetry(self):
+ # The hub calls uninstall(emit=..., cancel=...); emit is unused here
+ # (no subprocess phase) and cancel=None behaves like the plain call.
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td) / "audio.cpp"
+ checkout.mkdir()
+ with patch.object(make_server, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(make_server.servers, "stop"):
+ rc = make_server.uninstall(emit=lambda line: None,
+ cancel=None)
+ self.assertEqual(rc, 0)
+ self.assertFalse(checkout.exists())
+
+ def test_cancel_before_delete_keeps_checkout(self):
+ # Cancel is honored between phases only: once the server is stopped
+ # and cancellation is pending, the checkout deletion never starts.
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td) / "audio.cpp"
+ checkout.mkdir()
+ cancel = threading.Event()
+ cancel.set()
+ with patch.object(make_server, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(make_server.servers, "stop"):
+ rc = make_server.uninstall(cancel=cancel)
+ self.assertEqual(rc, 130)
+ self.assertTrue(checkout.exists())
+
if __name__ == "__main__":
unittest.main()
diff --git a/app/tests/test_backends_envs.py b/app/tests/test_backends_envs.py
index bf27f60..82d903a 100644
--- a/app/tests/test_backends_envs.py
+++ b/app/tests/test_backends_envs.py
@@ -106,6 +106,51 @@ class PipInstallTests(unittest.TestCase):
run.assert_not_called()
+class PipUninstallTests(unittest.TestCase):
+ def test_missing_env_is_success_without_running_pip(self):
+ with patch.object(envs, "env_exists", return_value=False), \
+ patch.object(envs.common, "run_console_subprocess") as run:
+ rc = envs.pip_uninstall(["qwen-tts"])
+ self.assertEqual(rc, 0)
+ run.assert_not_called()
+
+ def test_runs_pip_uninstall_against_the_venv_python(self):
+ calls = []
+
+ def fake_run(argv, **kwargs):
+ calls.append(list(argv))
+ return 0
+
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch.object(envs.common, "run_console_subprocess",
+ side_effect=fake_run):
+ rc = envs.pip_uninstall(["qwen-tts"])
+ self.assertEqual(rc, 0)
+ # The uninstall targets the venv's python.
+ self.assertEqual(calls[0][0], str(envs.env_python()))
+ self.assertIn("uninstall", calls[0])
+ self.assertIn("-y", calls[0])
+ self.assertIn("qwen-tts", calls[0])
+
+ def test_streams_to_emit_when_given(self):
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch.object(envs.common, "run_console_subprocess",
+ return_value=0) as run:
+ rc = envs.pip_uninstall(["qwen-tts"], emit="EMIT")
+ self.assertEqual(rc, 0)
+ # The task view's emit is forwarded so pip never touches the
+ # terminal behind curses.
+ self.assertEqual(run.call_args.kwargs.get("emit"), "EMIT")
+
+ def test_console_path_passes_no_emit(self):
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch.object(envs.common, "run_console_subprocess",
+ return_value=0) as run:
+ rc = envs.pip_uninstall(["qwen-tts"])
+ self.assertEqual(rc, 0)
+ self.assertIsNone(run.call_args.kwargs.get("emit"))
+
+
class ModuleAvailableTests(unittest.TestCase):
def test_false_when_env_missing(self):
with patch.object(envs, "env_exists", return_value=False):
diff --git a/app/tests/test_backends_faster.py b/app/tests/test_backends_faster.py
index eb1fa28..7e27b47 100644
--- a/app/tests/test_backends_faster.py
+++ b/app/tests/test_backends_faster.py
@@ -3,6 +3,7 @@
import json
import sys
import tempfile
+import threading
import unittest
import contextlib
from pathlib import Path
@@ -276,3 +277,60 @@ class SetupScreenTests(unittest.TestCase):
self.assertIs(mk_steps.call_args[0][0], settings)
mk_run.assert_called_once()
self.assertEqual(mk_run.call_args[0][2], steps)
+
+
+class UninstallTests(unittest.TestCase):
+ """uninstall: stop the server, pip-uninstall, delete the checkout."""
+
+ def test_pips_and_removes_checkout(self):
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td) / "faster-qwen3-tts"
+ checkout.mkdir()
+ with patch.object(make_voices, "_checkout",
+ return_value=checkout), \
+ patch.object(make_voices.servers, "stop") as mk_stop, \
+ patch.object(make_voices.common, "pip_uninstall",
+ return_value=0) as mk_pip:
+ rc = make_voices.uninstall(emit="EMIT")
+ self.assertEqual(rc, 0)
+ mk_stop.assert_called_once_with("faster")
+ # The task view's emit is forwarded so pip never touches the terminal.
+ mk_pip.assert_called_once_with(["faster-qwen3-tts"], emit="EMIT")
+ self.assertFalse(checkout.exists())
+
+ def test_no_checkout_still_uninstalls_the_package(self):
+ with patch.object(make_voices, "_checkout",
+ return_value=Path("/no/such/dir")), \
+ patch.object(make_voices.servers, "stop"), \
+ patch.object(make_voices.common, "pip_uninstall",
+ return_value=0) as mk_pip:
+ rc = make_voices.uninstall()
+ self.assertEqual(rc, 0)
+ mk_pip.assert_called_once_with(["faster-qwen3-tts"], emit=None)
+
+ def test_cancel_before_pip_skips_everything_after_stopping(self):
+ cancel = threading.Event()
+ cancel.set()
+ with patch.object(make_voices.servers, "stop") as mk_stop, \
+ patch.object(make_voices.common, "pip_uninstall") as mk_pip:
+ rc = make_voices.uninstall(cancel=cancel)
+ self.assertEqual(rc, 130)
+ mk_stop.assert_called_once_with("faster")
+ mk_pip.assert_not_called()
+
+ def test_cancel_before_delete_keeps_checkout(self):
+ # Cancel between phases: pip runs to completion, but a pending
+ # cancellation stops the checkout deletion from starting.
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td) / "faster-qwen3-tts"
+ checkout.mkdir()
+ cancel = threading.Event()
+ cancel.set()
+ with patch.object(make_voices, "_checkout",
+ return_value=checkout), \
+ patch.object(make_voices.servers, "stop"), \
+ patch.object(make_voices.common, "pip_uninstall",
+ return_value=0):
+ rc = make_voices.uninstall(cancel=cancel)
+ self.assertEqual(rc, 130)
+ self.assertTrue(checkout.exists())
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 965cbaa..ec079f5 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -1730,14 +1730,85 @@ class ConfigureBackendsDispatchTests(unittest.TestCase):
self.assertIs(result, tui.Wizard.BACK)
self.assertEqual(flashes, ["kaboom"])
- def test_screen_uninstall_runs_uninstall_and_goes_back(self):
- import contextlib
+ 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), \
+ patch.object(hub.tui, "confirm", return_value=True), \
+ patch.object(hub.taskview, "run_steps",
+ return_value=0) as mk_run, \
patch.object(info, "uninstall") as mk_uninstall, \
- patch.object(hub.tui, "suspend", contextlib.nullcontext):
+ patch.object(hub.tui, "flash"):
result = hub._Hub(None).screen_uninstall()
- mk_uninstall.assert_called_once_with()
+ # The uninstall runs as one task-view step on the session (no
+ # suspend); executing the step forwards emit/cancel to uninstall.
+ mk_run.assert_called_once()
+ self.assertEqual(mk_run.call_args[0][0], None)
+ steps = mk_run.call_args[0][2]
+ self.assertEqual([step.title for step in steps],
+ ["Uninstall qwen-tts"])
+ self.assertFalse(mk_run.call_args.kwargs["wait_on_finish"])
+ emit = lambda line: None
+ steps[0].work(emit, None)
+ mk_uninstall.assert_called_once_with(emit=emit, cancel=None)
+ self.assertIs(result, tui.Wizard.BACK)
+
+ def test_screen_uninstall_success_flashes_ok(self):
+ flashes = []
+
+ def fake_flash(scr, text, kind="warn"):
+ flashes.append((text, kind))
+
+ info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)
+ with patch.object(hub._Hub, "_pick_backend", return_value=info), \
+ patch.object(hub.tui, "confirm", return_value=True), \
+ patch.object(hub.taskview, "run_steps", return_value=0), \
+ patch.object(info, "uninstall"), \
+ patch.object(hub.tui, "flash", fake_flash):
+ result = hub._Hub(None).screen_uninstall()
+ self.assertIs(result, tui.Wizard.BACK)
+ self.assertEqual(flashes[-1], ("qwen-tts uninstalled.", "ok"))
+
+ def test_screen_uninstall_failure_flashes_error(self):
+ flashes = []
+
+ def fake_flash(scr, text, kind="warn"):
+ flashes.append((text, kind))
+
+ info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)
+ with patch.object(hub._Hub, "_pick_backend", return_value=info), \
+ patch.object(hub.tui, "confirm", return_value=True), \
+ patch.object(hub.taskview, "run_steps", return_value=1), \
+ patch.object(info, "uninstall"), \
+ patch.object(hub.tui, "flash", fake_flash):
+ result = hub._Hub(None).screen_uninstall()
+ self.assertIs(result, tui.Wizard.BACK)
+ self.assertEqual(flashes[-1][1], "err")
+ self.assertIn("Could not fully uninstall", flashes[-1][0])
+
+ def test_screen_uninstall_confirm_declined_skips_uninstall(self):
+ info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)
+ with patch.object(hub._Hub, "_pick_backend", return_value=info), \
+ patch.object(hub.tui, "confirm", return_value=False) \
+ as mk_confirm, \
+ patch.object(hub.taskview, "run_steps") as mk_run, \
+ patch.object(info, "uninstall") as mk_uninstall:
+ result = hub._Hub(None).screen_uninstall()
+ mk_run.assert_not_called()
+ mk_uninstall.assert_not_called()
+ self.assertIs(result, tui.Wizard.BACK)
+ # The confirm names the backend and is Esc-able (cancel_value set).
+ self.assertIn("Uninstall qwen-tts?", mk_confirm.call_args[0][1])
+
+ def test_screen_uninstall_esc_on_confirm_backs_out(self):
+ info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)
+ with patch.object(hub._Hub, "_pick_backend", return_value=info), \
+ patch.object(hub.tui, "confirm",
+ return_value=tui.Wizard.BACK), \
+ patch.object(hub.taskview, "run_steps") as mk_run, \
+ patch.object(info, "uninstall") as mk_uninstall:
+ result = hub._Hub(None).screen_uninstall()
+ mk_run.assert_not_called()
+ mk_uninstall.assert_not_called()
self.assertIs(result, tui.Wizard.BACK)
def _capture_flashes(self):
diff --git a/app/ui/hub.py b/app/ui/hub.py
index f937b84..d20b28e 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -10,10 +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 (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
+opaque leaf screens on this same session; every long step — setup tails,
+model downloads, server start/stop, and uninstall — runs inside the
+``ui.taskview`` task view, so the user is never dropped to the console.
+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.
@@ -253,11 +253,41 @@ class _Hub:
return self.screen_setup(info)
def screen_uninstall(self):
+ """Pick a backend, confirm, then uninstall it inside the task view.
+
+ Esc on the picker or the confirm (or answering No) backs out
+ untouched. A confirmed uninstall runs as one task-view step on this
+ session — no console drop: the backend's ``uninstall`` stops its
+ servers, pip-uninstalls, and deletes its files, honoring cancel
+ between phases only. A flash summarizes the result and the stack
+ lands back on the Configure menu, whose status table re-detects the
+ removal.
+ """
info = self._pick_backend(installed_only=True)
if info is None:
return tui.Wizard.BACK
- with tui.suspend(self.stdscr):
- info.uninstall()
+ answer = tui.confirm(
+ self.stdscr, f"Uninstall {info.label}?",
+ body=[f"This permanently removes {info.label} from this "
+ "machine: managed servers are stopped and every installed "
+ "file — including downloaded models — is deleted."],
+ default=False, cancel_value=tui.Wizard.BACK)
+ if answer is not True:
+ return tui.Wizard.BACK
+ title = f"Uninstall {info.label}"
+ step = taskview.TaskStep(
+ title,
+ lambda emit, cancel: info.uninstall(emit=emit, cancel=cancel))
+ rc = taskview.run_steps(self.stdscr, title, [step],
+ wait_on_finish=False)
+ # The uninstallers warn-and-continue (a failed pip step still
+ # returns 0), so rc == 0 means "finished"; anything else covers a
+ # failure or an Esc-cancelled run between phases.
+ if rc == 0:
+ tui.flash(self.stdscr, f"{info.label} uninstalled.", "ok")
+ else:
+ tui.flash(self.stdscr,
+ f"Could not fully uninstall {info.label}.", "err")
return tui.Wizard.BACK
def _pick_backend(self, installed_only: bool):