aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 18:18:21 -0400
committerhistoria <historiavg@proton.me>2026-08-26 18:18:21 -0400
commit544486a374cd5cae7acce1302648d8dad079db48 (patch)
tree5987cdb6790844252ec655465eacea8ae526e3a8
parent6ccb6d443d2fb871b43d96ea61a95bc3e6a92355 (diff)
downloadtts-audiobook-generator-544486a374cd5cae7acce1302648d8dad079db48.tar.gz
fix: default directory when choosing voices in tui
-rw-r--r--.gitignore6
-rw-r--r--.opencode/plans/1776156239868-shiny-island.md185
-rw-r--r--app/backends/audiocpp/wizard.py16
-rwxr-xr-xapp/backends/faster.py2
-rw-r--r--app/backends/qwen.py9
-rw-r--r--app/tests/test_backends.py6
-rw-r--r--app/tests/test_backends_audiocpp.py81
-rw-r--r--app/tests/test_hub.py8
-rw-r--r--app/tests/test_tui.py20
-rw-r--r--app/ui/hub.py20
-rw-r--r--app/ui/tui.py11
11 files changed, 147 insertions, 217 deletions
diff --git a/.gitignore b/.gitignore
index 313ac60..538fd12 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,9 @@ input/*.txt
*.flac
*.ogg
+.opencode/
+.ruff_cache/
+
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
@@ -231,9 +234,6 @@ cython_debug/
# Temporary file for partial code execution
tempCodeRunnerFile.py
-# Ruff stuff:
-.ruff_cache/
-
# PyPI configuration file
.pypirc
diff --git a/.opencode/plans/1776156239868-shiny-island.md b/.opencode/plans/1776156239868-shiny-island.md
deleted file mode 100644
index c6c69e7..0000000
--- a/.opencode/plans/1776156239868-shiny-island.md
+++ /dev/null
@@ -1,185 +0,0 @@
-# Plan: backend setup states, always-buildable audio.cpp, in-TUI long steps
-
-## Problem summary
-
-1. **Status granularity**: `audiocpp.detect()` sets `installed = (binary built)`. A cloned-but-not-built
- checkout collapses into "unavailable" (red, dimmed) — indistinguishable from "nothing there".
-2. **Build trap**: once a `server.json` exists, the wizard's `_after_sync()` hits
- `existing_backend in BACKENDS → build=False` and *skips the build offer forever*. Declining the
- build on first install → backend stays "unavailable" with no TUI path to build it. The hub's
- "Download Missing Models" action is also gated on `status.installed`, so it disappears too.
-3. **Console drops**: clone (`audiocpp._wizard`), the whole `_execute` tail (build / whisper
- transcription / model downloads — all three backends' `setup_screen`), and the hub's
- "Download Missing Models" action all run under `tui.suspend`, dumping the user to the console.
-
-## Existing machinery to reuse
-
-- `ui/runview.py`: proven pattern for a full-screen curses view driven by a worker thread + event
- queue + timed `getch` redraw + Esc→confirm-cancel.
-- `app/audio.cpp/tools/model_manager_v2.py install <pkg> --progress --cancel-file F`: emits
- `AUDIOCPP_PROGRESS downloaded=N total=M` lines and supports graceful cancel (already upstream).
-- `git clone --progress` emits `Receiving objects: NN%`; cmake/make emit `[ NN%]`, ninja `[d/t]`.
-- `tests.test_tui.FakeCurses/FakeScreen`: fake terminal for widget tests (as in test_runview.py).
-
-## Design
-
-### 1. New `partial` state label on `BackendStatus`
-
-`app/backends/__init__.py`: add `partial: str = ""` — an optional, more specific label for a
-backend that is set up only part-way. Documented values used by audio.cpp:
-- checkout exists, no `audiocpp_server` binary → `"downloaded (not built)"`
-- binary built, no `server.json` → `"built (not configured)"`
-
-`app/ui/hub.py::_status_mark` (before the installed/unavailable branches, after running):
-- `status.partial` set → `(status.partial, "warn", "dim" if not status.installed else "body")`
- (amber text; dimmed name while still unusable, matching the "dim = unusable" convention).
-- `audiocpp.detect()` populates `partial`; details lines already carry specifics.
- qwen/faster unchanged (they can adopt `partial` later).
-
-### 2. New in-TUI task view: `app/ui/taskview.py`
-
-Full-screen DOS-style view (mirrors runview.py conventions) that runs an ordered list of steps
-inside the hub's curses session:
-
-```python
-@dataclass
-class TaskStep:
- title: str
- work: Callable[[emit, cancel], int] # emit(line) streams output; cancel is a threading.Event
-
-def run_steps(scr, title: str, steps: list[TaskStep]) -> int # 0 = all ok
-```
-
-- Worker thread runs steps sequentially; events over a queue: `step_start`, `line`, `step_done`,
- `done`. Main thread redraws on a 250 ms `getch` timeout.
-- Layout: dialog frame with the step list (`[OK]` / `[..]`+spinner+elapsed / `[FAIL]` / pending),
- an optional progress bar under the current step, and a dim scrolling log tail (last ~10 lines).
-- Progress parsing per emitted line (bar hidden until first match):
- - `AUDIOCPP_PROGRESS downloaded=(\d+) total=(\d+)` → exact bytes bar (line not shown in log);
- - `(\d{1,3})%` (git `Receiving objects: 45%`, make `[ 45%]`) → percent bar;
- - `[(\d+)/(\d+)]` (ninja) → count bar.
-- Esc/q while running → `tui.confirm("Cancel?")` → sets `cancel` (subprocess runners kill the
- child; in-process steps check between units of work) → summary shows "cancelled".
-- Terminal state: per-step marks + result line; waits for a keypress so failures never scroll away.
-- `run_steps` returns the first non-zero step rc (0 when all succeed), so callers can flash/raise.
-
-### 3. Streaming subprocess support: `app/backends/common.py`
-
-- Extend `run_console_subprocess(argv, cwd=None, *, emit=None, cancel=None, on_cancel=None)`:
- - `emit=None` → today's behavior (inherit terminal; used by CLI/non-TUI paths).
- - `emit` given → `Popen(stdout=PIPE, stderr=STDOUT, start_new_session=True)`; read chunks,
- split lines on `\n` and `\r` (covers tqdm/git `\r` updates), call `emit(line)`.
- - On `cancel.is_set()`: call `on_cancel()` if given (e.g. touch a `--cancel-file`), then
- killpg TERM → grace → KILL (same escalation as `servers.py`); return 130.
-- `git_clone(url, target, *, emit=None, cancel=None)`: adds `--progress` when emitting.
-
-`app/backends/envs.py`: `pip_install(packages, *, emit=None, cancel=None)` threads the args
-through; passes `--progress-bar off` when emitting (clean `Downloading X (12 MB)` lines instead
-of carriage-return spam). `pip_uninstall` unchanged (hub uninstall stays on the console).
-
-### 4. audio.cpp backend (`app/backends/audiocpp.py`)
-
-**Wizard — always offer the build when not built:**
-- `_after_sync()`: drop the `existing_backend in BACKENDS → build=False` early return. The backend
- menu is skipped only when `detected_backend` is not None (a unique built backend exists) or a
- flag supplied the value. Otherwise → `screen_backend`.
-- `screen_backend`: default-select `existing_backend` (recorded in server.json) when present;
- neutral title ("Which inference backend should audiocpp_server use?") since it now serves both
- the not-built and the multi-build cases.
-- `screen_build`: shown whenever the wizard's chosen backend has no built binary — add
- `built_server_binary(audiocpp_dir, backend) -> Optional[Path]` (per-backend check under
- `build/*<backend>*/bin/`) and skip the prompt when it exists (multi-build checkouts picking an
- already-built backend don't get a pointless build offer). Declining stays possible, but now the
- hub offers a dedicated build action afterwards (below), so "not now" no longer strands the user.
-
-**Step-based `_execute`:**
-- Refactor `_execute(settings, args, emit=None, cancel=None)` into
- `_execute_steps(settings, args) -> list[taskview.TaskStep]` plus a thin runner: console paths
- (`run_tui`, `main`) run the steps with `emit=None` (byte-identical console output to today);
- `setup_screen` runs them via `taskview.run_steps(stdscr, "Setting up audio.cpp", steps)` — no
- more `tui.suspend`. Steps: build (if `settings["build"]`), transcribe voices (cancel checked
- between .wav files), write server.json + config sync, delete unused models (modify flow),
- download models (if `settings["download"]`); the launch hint is the final log line. A failing
- step marks `[FAIL]` and the run continues where today it would only warn (build failure still
- writes server.json); `run_steps`' rc feeds the hub flash.
-- `build_audiocpp(audiocpp_dir, backend, *, emit=None, cancel=None)`: streams via the new helper.
-- `_install_models(..., emit=None, cancel=None)` / `install_models(...)`: when emitting, probe
- `model_manager_v2.py` once for `--progress` support (source contains `AUDIOCPP_PROGRESS`) and
- pass `--progress --cancel-file <tmp>`; the cancel file is wired as the runner's `on_cancel`.
- Without support (older checkout) or emit=None: today's plain streaming.
-- Wizard clone step: `taskview.run_steps(stdscr, "Clone audio.cpp", [clone step])` instead of
- `tui.suspend`; on rc != 0 raise `_TuiError` exactly as today.
-- `detect()`: set `partial` per the states above.
-
-**New `build_screen(stdscr)`** (exported for the hub): when the checkout has no built binary —
-menu to pick the backend (default: server.json's `backend`, else cuda) → `taskview.run_steps`
-with the build step → on success, if a server.json exists, rewrite its `backend` field to the
-built backend and flash "build complete"; on failure flash the log tail hint. Returns 0/1.
-
-### 5. faster + qwen backends
-
-- Same `setup_screen` switch: `_execute(settings)` → `_execute_steps(settings)` +
- `taskview.run_steps(...)`; console paths keep `emit=None`.
- - faster steps: pip install `faster-qwen3-tts[demo]`, git clone, transcribe + write voices.json,
- sync config.
- - qwen steps: pip install `qwen-tts`, sync config.
-- `detect()` unchanged (no `partial` states for now).
-
-### 6. Hub (`app/ui/hub.py`)
-
-- `_status_mark`: `partial` handling (above) + docstring.
-- `screen_configure`:
- - New action **"Build audio.cpp server"** when `audiocpp_status` has a checkout but no built
- binary (`audiocpp_backend.find_local_checkout()` + `find_audiocpp_server_bin()`), calling
- `audiocpp_backend.build_screen(self.stdscr)` as an inline action (continue loop).
- - "Download Missing Models (audio.cpp)": gate on `audiocpp_status.configured` (server.json
- exists) instead of `.installed` — a not-built backend can still need its models.
-- `_download_models_action`: run `install_models` through `taskview.run_steps` (one step per
- install id, real byte-progress via `AUDIOCPP_PROGRESS`) instead of `tui.suspend`.
-- Server start/stop + uninstall actions: unchanged (still `tui.suspend`) — out of scope.
-
-### 7. Statuses shown after the change (audio.cpp)
-
-| state | status text | color |
-|---|---|---|
-| no checkout | `unavailable` | red, dim name |
-| checkout, not built | `downloaded (not built)` | amber, dim name |
-| built, no server.json | `built (not configured)` | amber |
-| built + server.json | `installed` / `installed (models missing)` | amber |
-| server up | `running [local]` etc. | green |
-
-### 8. Tests
-
-- `tests/test_hub.py`: `_status_mark` partial cases; configure-menu test for the new Build action
- presence/absence; Download-Missing-Models gating on `configured`.
-- `tests/test_backends_audiocpp.py`: update `SetupScreenTests` (patch `taskview.run_steps`
- instead of `tui.suspend`); wizard tests for the new `_after_sync`/`screen_build` flow (modify
- flow with existing server.json now offers the build; already-built backend skips it);
- `detect()` partial cases; `built_server_binary`.
-- `tests/test_backends_faster.py` / `tests/test_backends.py` (qwen): same setup_screen update.
-- `tests/test_backends_envs.py`: `pip_install` call now passes emit/cancel kwargs — adjust
- assertions.
-- New `tests/test_taskview.py`: reuse `FakeCurses`/`FakeScreen` — step sequencing, progress-line
- parsing (`AUDIOCPP_PROGRESS`, `%`, `[d/t]`), cancel flow, failure marks.
-- New tests for `common.run_console_subprocess` streaming mode (emit lines, cancel → 130).
-
-### 9. Docs
-
-- `app/docs/backend-audiocpp.md`: mention the Build action + the new intermediate statuses.
-- README Quick Start step 4: one sentence that install/clone/build/download run inside the TUI
- with progress.
-- Module docstrings (`backends/__init__.py` BackendStatus, hub `_status_mark`, audiocpp wizard
- docstring) updated to match. No AGENTS.md exists.
-
-## Out of scope
-
-- Standalone CLI paths (`python app/backends/audiocpp.py`, non-interactive runs) keep the plain
- console tail — the TUI improvements target the hub session.
-- Hub server start/stop and uninstall console drops.
-
-## Verification
-
-- `python -m pytest app/tests -x -q` (full suite) plus targeted runs of the touched test files.
-- Manual smoke (described in code review): drive the hub against a fake checkout to see
- `downloaded (not built)` → Build action → `installed`; decline build in the wizard → status +
- Build action remain.
diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py
index 2034827..831ae3d 100644
--- a/app/backends/audiocpp/wizard.py
+++ b/app/backends/audiocpp/wizard.py
@@ -14,9 +14,7 @@ from backends import common
from backends.common import (
APP_DIR,
PROMPT_TEXT_FILENAME,
- TTS_ROOT,
VOICES_DIR,
- detect_wav_dir,
find_wav_files,
read_prompt_text,
resolve_wav_dir_arg,
@@ -336,7 +334,8 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
picked = tui.checkbox_tree(
stdscr, "Select TTS models to host",
tree_families, expand_all=args.all_packages,
- back_value=_GO_BACK, checked=checked_set)
+ back_value=_GO_BACK, checked=checked_set,
+ start_on_buttons=True)
if picked is _GO_BACK:
return tui.Wizard.BACK
chosen: Dict[str, List[dict]] = {}
@@ -452,14 +451,17 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
s["backend"] = None # decided by the form
s["build"] = None
- # Clone-voice directory seed: the project voices/ dir (detected),
- # or the voice_dir recorded by the server.json being modified.
+ # Clone-voice directory seed: the voice_dir recorded by the
+ # server.json being modified, else the project voices/ dir (the
+ # same default the --wavs flag documents). No auto-detection: the
+ # field must never start blank.
wav_start = None
if s["include_clone"]:
- wav_start = detect_wav_dir(s["audiocpp_dir"], TTS_ROOT)
if isinstance(s["existing_voice_dir"], str) \
and s["existing_voice_dir"]:
wav_start = Path(s["existing_voice_dir"])
+ else:
+ wav_start = VOICES_DIR
s["wav_dir"] = wav_start
fields: List[dict] = []
@@ -572,7 +574,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
result = tui.form(
stdscr, "Configure audio.cpp", fields,
- buttons=("Continue!", "Cancel"),
+ buttons=("Continue", "Cancel"),
start_on_buttons=False, back_value=tui.Wizard.BACK)
if result is tui.Wizard.BACK:
return tui.Wizard.BACK
diff --git a/app/backends/faster.py b/app/backends/faster.py
index 57e6752..60ddddf 100755
--- a/app/backends/faster.py
+++ b/app/backends/faster.py
@@ -288,7 +288,7 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]:
result = tui.form(
stdscr, "Set up faster-qwen3-tts", fields,
- buttons=("Continue!", "Cancel"),
+ buttons=("Continue", "Cancel"),
start_on_buttons=False, back_value=_GO_BACK)
if result is _GO_BACK:
return None
diff --git a/app/backends/qwen.py b/app/backends/qwen.py
index 5f45580..592b0eb 100644
--- a/app/backends/qwen.py
+++ b/app/backends/qwen.py
@@ -33,7 +33,7 @@ from backends import (
)
from converter import config
from converter.clients import QWEN3_TTS_SPEAKERS
-from ui import taskview, tui
+from ui import taskview
QWEN_PIP_PKG = "qwen-tts"
QWEN_CUSTOMVOICE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
@@ -107,14 +107,13 @@ def setup_screen(stdscr) -> int:
"""Run the setup on an existing curses screen (the hub's).
There are no questions: settings are computed up front and the install
- runs inside the TUI task view on this same screen (skipped entirely
- when nothing needs installing). Returns 0 always — the flow cannot be
- aborted, so Esc/Ctrl-C never short-circuits it.
+ runs inside the TUI task view on this same screen — skipped entirely
+ (a silent no-op) when nothing needs installing. Returns 0 always —
+ the flow cannot be aborted, so Esc/Ctrl-C never short-circuits it.
"""
args = build_parser().parse_args([])
settings = _wizard(stdscr, args)
if not settings["do_install"]:
- tui.flash(stdscr, "qwen-tts is already installed.", "ok")
return 0
return taskview.run_steps(stdscr, "Setting up qwen-tts",
_execute_steps(settings))
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py
index 15a8649..99742f3 100644
--- a/app/tests/test_backends.py
+++ b/app/tests/test_backends.py
@@ -356,19 +356,17 @@ class QwenSetupScreenTests(unittest.TestCase):
The qwen wizard asks nothing (ports live in Settings, the speaker is
chosen on Generate), so it cannot be aborted: an already-installed
- package is a no-op flash, everything else runs in the task view.
+ package is a silent no-op, everything else runs in the task view.
"""
- def test_already_installed_flashes_and_skips_the_task_view(self):
+ def test_already_installed_is_a_silent_noop(self):
from backends import qwen
settings = {"do_install": False}
with patch.object(qwen, "_wizard", return_value=settings) as mk_wizard, \
- patch.object(qwen.tui, "flash") as mk_flash, \
patch.object(qwen.taskview, "run_steps") as mk_run:
rc = qwen.setup_screen(None)
self.assertEqual(rc, 0)
mk_wizard.assert_called_once()
- mk_flash.assert_called_once()
mk_run.assert_not_called()
def test_missing_package_runs_the_tail_in_the_task_view(self):
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py
index 8cd7981..37745f3 100644
--- a/app/tests/test_backends_audiocpp.py
+++ b/app/tests/test_backends_audiocpp.py
@@ -1963,6 +1963,87 @@ class WizardNavigationTests(unittest.TestCase):
self.assertFalse(settings["download"]) # no manager script here
self.assertTrue(settings["sync_model_ids"])
+ def test_tree_screen_starts_on_confirm(self):
+ # The model-tree screen opens with focus on Confirm so Enter
+ # accepts the seeded/checked selection immediately.
+ checkout = self._checkout()
+ catalog = make_server.catalog.load_model_catalog(checkout)
+ qwen3 = next(i for i, entry in enumerate(catalog)
+ if entry["family"] == "qwen3_tts")
+ captured = {}
+
+ def fake_tree(*args, **kwargs):
+ captured.update(kwargs)
+ return [(qwen3, "Qwen3-TTS-12Hz-1.7B-Base-GGUF")]
+
+ def fake_form(stdscr, title, fields, **kwargs):
+ return {f["key"]: f["value"] for f in fields}
+
+ with patch.object(make_server.build, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(tui, "checkbox_tree",
+ side_effect=fake_tree), \
+ patch.object(tui, "form", side_effect=fake_form):
+ make_server.wizard._wizard(None, self._args(),
+ make_server.wizard.build_parser())
+ self.assertTrue(captured.get("start_on_buttons"))
+
+ def test_wav_dir_seeded_from_existing_voice_dir(self):
+ # A modify run loads the Voice clone .wav directory from the
+ # server.json being configured instead of starting blank.
+ checkout = self._checkout()
+ recorded_voices = checkout.parent / "recorded-voices"
+ (checkout / "server.json").write_text(json.dumps({
+ "host": "127.0.0.1", "port": 8080, "backend": "cuda",
+ "models": [], "voice_dir": str(recorded_voices),
+ }), encoding="utf-8")
+ catalog = make_server.catalog.load_model_catalog(checkout)
+ qwen3 = next(i for i, entry in enumerate(catalog)
+ if entry["family"] == "qwen3_tts")
+
+ def fake_tree(*args, **kwargs):
+ return [(qwen3, "Qwen3-TTS-12Hz-1.7B-Base-GGUF")]
+
+ def fake_form(stdscr, title, fields, **kwargs):
+ by_key = {f["key"]: f for f in fields}
+ self.assertEqual(by_key["wav_dir"]["value"],
+ Path(recorded_voices))
+ return {f["key"]: f["value"] for f in fields}
+
+ with patch.object(make_server.build, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(tui, "checkbox_tree",
+ side_effect=fake_tree), \
+ patch.object(tui, "form", side_effect=fake_form):
+ make_server.wizard._wizard(None, self._args(),
+ make_server.wizard.build_parser())
+
+ def test_wav_dir_defaults_to_project_voices_when_unconfigured(self):
+ # Without a voice_dir in server.json the field starts on the
+ # project's voices/ directory — never blank.
+ checkout = self._checkout()
+ (checkout / "server.json").write_text(
+ json.dumps({"models": []}), encoding="utf-8")
+ catalog = make_server.catalog.load_model_catalog(checkout)
+ qwen3 = next(i for i, entry in enumerate(catalog)
+ if entry["family"] == "qwen3_tts")
+
+ def fake_tree(*args, **kwargs):
+ return [(qwen3, "Qwen3-TTS-12Hz-1.7B-Base-GGUF")]
+
+ def fake_form(stdscr, title, fields, **kwargs):
+ by_key = {f["key"]: f for f in fields}
+ self.assertEqual(by_key["wav_dir"]["value"], common.VOICES_DIR)
+ return {f["key"]: f["value"] for f in fields}
+
+ with patch.object(make_server.build, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(tui, "checkbox_tree",
+ side_effect=fake_tree), \
+ patch.object(tui, "form", side_effect=fake_form):
+ make_server.wizard._wizard(None, self._args(),
+ make_server.wizard.build_parser())
+
def test_build_offer_hidden_when_backend_already_built(self):
# A checkout with a built binary for the chosen backend must not
# show (or honor) a build offer.
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 96e4706..6657540 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -369,11 +369,11 @@ class SubmenuStatusTableTests(unittest.TestCase):
return_value="/usr/bin/ffmpeg"):
result = hub._Hub(None).screen_configure()
self.assertIs(result, tui.Wizard.BACK)
- # Configure (qwen installed), Install (faster uninstalled), then
- # Uninstall (qwen installed); no audio.cpp means no model actions.
+ # Install (faster uninstalled), then Uninstall (qwen installed);
+ # no audio.cpp means no model actions. qwen has no Configure entry
+ # (its wizard asks nothing to configure).
self.assertEqual([label for label, _ in captured["options"]],
- ["Configure qwen-tts", "Install Backend",
- "Uninstall Backend"])
+ ["Install Backend", "Uninstall Backend"])
# ...the shared status table carries the states instead.
self.assertEqual(captured["table_title"], "Backend status")
self.assertEqual(
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index b15419b..b839a71 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -981,6 +981,26 @@ class CheckboxTreeTests(TuiTestCase):
checked={(0, "pkg-b"), (1, "pkg-c")})
self.assertEqual(picked, [(0, "pkg-b"), (1, "pkg-c")])
+ def test_start_on_buttons_confirms_immediately(self):
+ # start_on_buttons=True (the seeded modify flow): Enter alone
+ # accepts the pre-checked tree with no Tab first.
+ screen = FakeScreen(keys=[10])
+ picked = tui.checkbox_tree(
+ screen, "Pick models", self.FAMILIES,
+ checked={(0, "pkg-b"), (1, "pkg-c")}, start_on_buttons=True)
+ self.assertEqual(picked, [(0, "pkg-b"), (1, "pkg-c")])
+
+ def test_start_on_buttons_without_selection_flashes(self):
+ # Focus starting on Confirm changes nothing else: Enter with an
+ # empty tree flashes and stays (the flash dismiss consumes one
+ # scripted key); the user then moves back to the rows, checks
+ # one, and confirms for real.
+ keys = [10, 9, 9, 10, 9, 10]
+ screen = FakeScreen(keys=keys)
+ picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES,
+ start_on_buttons=True)
+ self.assertEqual(picked, [(0, "pkg-a")])
+
def test_prechecked_options_draw_as_checked(self):
screen = FakeScreen(keys=[9, 10])
tui.checkbox_tree(screen, "Pick models", self.FAMILIES,
diff --git a/app/ui/hub.py b/app/ui/hub.py
index b41b369..6a82c83 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -150,9 +150,10 @@ class _Hub:
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.
+ separated from the rest by a blank line. remaining actions are populated from the detected statuses:
+ configure each configurable backend (qwen asks nothing to
+ configure, so it has no entry), install (backends with nothing on
+ disk), and uninstall.
Selecting one pushes the next screen; Esc pops back to the main
menu. The Build action downloads any missing models alongside the
build (a split view), so it heals a configured-but-unbuilt backend
@@ -197,7 +198,7 @@ class _Hub:
options.append(tui.MENU_SEPARATOR)
options += [(f"Configure {info.label}", ("configure", info.key))
- for info in installed]
+ for info in installed if _configurable(info)]
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):
@@ -573,6 +574,17 @@ def _server_action_step(spec, action: str):
return taskview.TaskStep(title, work), log_path
+def _configurable(info) -> bool:
+ """True when INFO has a setup wizard worth running to reconfigure.
+
+ qwen-tts is excluded: its wizard asks no questions (ports live in the
+ Settings screen, the speaker is chosen per run on Generate audiobooks),
+ so a "Configure" entry could only ever flash "already installed".
+ Installing it stays possible via Install Backend.
+ """
+ return info.key != "qwen"
+
+
def _installable(info, by_key: dict) -> bool:
"""True when INFO has nothing on disk yet — an install-entry candidate.
diff --git a/app/ui/tui.py b/app/ui/tui.py
index cc21c0b..d91274b 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -1245,7 +1245,8 @@ def checkbox_tree(scr, title: str, families: List[dict],
footer: Optional[str] = None,
expand_all: bool = False,
back_value: object = None,
- checked: Optional[set] = None) -> List[Tuple[int, str]]:
+ checked: Optional[set] = None,
+ start_on_buttons: bool = False) -> List[Tuple[int, str]]:
"""Pick model families and packages from an expandable tree.
FAMILIES is a list of dicts (one per family) shaped like::
@@ -1272,8 +1273,10 @@ def checkbox_tree(scr, title: str, families: List[dict],
family starts expanded. CHECKED (a set of (family_index, option_key)
pairs) pre-checks those options instead, expanding every family that
holds a checked option and placing the cursor on the first such
- family — the "modify an existing config" entry point. A
- "[recommended]" tag is shown only when a family has more than one
+ family — the "modify an existing config" entry point.
+ START_ON_BUTTONS puts the initial focus on Confirm, so Enter accepts
+ the tree as it stands (the seeded modify selection) immediately.
+ A "[recommended]" tag is shown only when a family has more than one
option — a single option needs no tag.
Family and option rows are left-justified like a DOS list. Esc (or
'q') aborts the wizard unless BACK_VALUE is given (not None), in
@@ -1312,7 +1315,7 @@ def checkbox_tree(scr, title: str, families: List[dict],
first_checked = min((index for index, _option_key in checked),
default=None)
cursor = 0
- on_buttons = False
+ on_buttons = start_on_buttons
btn_index = 0
while True:
nodes = visible_nodes()