fix(settings): stage Advanced model spec until Download is confirmed (#908)

* fix(settings): stage Advanced model spec until Download is confirmed

Picker changes in Speech Model -> Advanced staged a spec but immediately triggered a model download for every intermediate selection. Gate the auto-apply download path so a missing model only stages: a Download action on the model info card now runs apply_settings() as the single explicit confirmation. Simple-mode questions keep the instant apply-and-download flow.

Fixes #894

* fix(settings): repaint model info card after a completed download

The post-download paths repopulated pickers and resynced the engine but never repainted the info card, so a finished download left the amber status and the Download action visible for a model already on disk. The cancel path already repainted via the idle resync; give success its own.

* fix(settings): keep staged spec through picker rebuilds and save non-model edits

* fix(settings): persist non-model edits without a model-reloading reconfigure

* fix(settings): live-apply non-model edits, keep whispercpp params saved-only while staged

* fix(settings): live-set changed VAD/timeout attrs without reconfigure while staged

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-10-07 17:17:08 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 775863fd30
commit 329a7d7f6a
2 changed files with 300 additions and 11 deletions
+125 -6
View File
@@ -318,6 +318,20 @@ def _model_specialization_display_name(model_name: str) -> str:
#: attribute, so tests driving the methods against a Mock still reach it.
FOLLOW_LAYOUT_ENGINES = ("whisper", "whisper_cpp", "faster_whisper", "remote_api")
#: Selected-setting keys that make up the model spec. While a spec that needs a
#: download is staged (#894), these stay out of the saved config until the
#: explicit Download confirmation; every other key applies and saves as usual.
_MODEL_SPEC_KEYS = ("engine", "model_size", "model_variant", "language")
#: Engine attributes a Settings edit can update live with the same clamping
#: ``reconfigure()`` applies — minus its recording cancel and any model
#: reload. Every other key (all ``whispercpp_*`` load-time params) stays
#: saved-only while a download is staged (#894).
_LIVE_ENGINE_SETTINGS = {
"vad_sensitivity": lambda value: max(1, min(5, int(value))),
"silence_timeout": lambda value: max(0.5, min(5.0, float(value))),
}
def _is_following_layout(dialog: Any) -> bool:
"""Whether the follow-keyboard-layout mode is on for this dialog (#821).
@@ -2666,6 +2680,10 @@ class SettingsDialog(Gtk.Dialog):
# Cached so Mock-driven tests and early calls read a real bool.
self._follow_layout_active = False
self._applying_settings = False # Flag to prevent recursive settings application
# The last Advanced picker spec that needs a download and is still
# unconfirmed: picker rebuilds keep offering it instead of falling back
# to the saved model, and a real apply or resync clears it (#894).
self._staged_model_spec: Optional[dict] = None
self._advanced_prompt_dirty = False
self._language_candidates_dirty = False
# Deferred text edits stashed when the dialog closes mid-apply; persisted
@@ -4757,6 +4775,15 @@ class SettingsDialog(Gtk.Dialog):
self.model_info_subtitle.get_style_context().add_class("model-info-subtitle")
self.model_info_card.pack_start(self.model_info_subtitle, False, False, 0)
# Picking a spec only stages it; this button is the explicit
# confirmation that turns the staged spec into a download (#894).
self.model_download_button = Gtk.Button(label="Download")
self.model_download_button.get_style_context().add_class("suggested-action")
self.model_download_button.set_halign(Gtk.Align.START)
self.model_download_button.set_no_show_all(True)
self.model_download_button.connect("clicked", self._on_download_model_clicked)
self.model_info_card.pack_start(self.model_download_button, False, False, 0)
# The recommendation used to be a plain label, which left the panel stating
# the right answer while the pickers kept the wrong one (#778).
self.model_recommendation_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
@@ -8080,11 +8107,22 @@ class SettingsDialog(Gtk.Dialog):
combo_id = size if engine == "faster_whisper" else size.capitalize()
self.model_combo.append(combo_id, display_text)
# Determine which model to select
# Determine which model to select. A staged spec wins over the
# saved model so a rebuild (e.g. a language change) keeps the
# unconfirmed pick instead of snapping back to the saved one.
saved_model = saved_model_for_engine.lower()
valid_models = [m.lower() for m in ENGINE_MODELS.get(engine, [])]
if saved_model in valid_models:
staged = self._staged_model_spec
staged_model = None
if isinstance(staged, dict) and staged.get("engine") == engine:
candidate = str(staged.get("model_size") or "").lower()
if candidate in valid_models:
staged_model = candidate
if staged_model:
selected = staged_model
elif saved_model in valid_models:
selected = saved_model
elif downloaded_models:
selected = downloaded_models[0]
@@ -8135,7 +8173,16 @@ class SettingsDialog(Gtk.Dialog):
recommended_model, _ = self._get_recommended_whispercpp_model_for_language()
recommended_size = get_whispercpp_model_size(recommended_model)
saved_model = self._resolve_saved_whispercpp_variant(saved_model_for_engine)
staged = self._staged_model_spec
staged_model = ""
if isinstance(staged, dict) and staged.get("engine") == "whisper_cpp":
staged_model = str(staged.get("model_size") or "")
if staged_model in WHISPERCPP_MODEL_INFO:
# The staged spec survives rebuilds until its Download confirmation
# or a real apply (#894); it wins over the saved seed.
saved_model = staged_model
else:
saved_model = self._resolve_saved_whispercpp_variant(saved_model_for_engine)
if saved_model not in WHISPERCPP_MODEL_INFO:
saved_model = (
recommended_model if recommended_model in WHISPERCPP_MODEL_INFO else "tiny"
@@ -8948,7 +8995,9 @@ class SettingsDialog(Gtk.Dialog):
)
self.config_manager.set("speech_recognition", "simple_second_language", second or "")
self._update_simple_visibility()
self._auto_apply_settings()
# A simple answer resolves to a complete spec, so its pick keeps the
# instant apply-and-download flow the Advanced pickers no longer get.
self._auto_apply_settings(allow_download=True)
def _commit_or_restore_simple_language_entry(self) -> bool:
"""Resolve text typed into the simple language box, or restore the last pick."""
@@ -9124,6 +9173,9 @@ class SettingsDialog(Gtk.Dialog):
else:
status = f"<span foreground='#e5a50a'>Download ~{_format_size(info['size_mb'])}</span>"
self.model_info_subtitle.set_markup(f"{extra_info} · {status}")
# The confirm action follows the displayed spec: offered only while it
# names a model that is not on disk (#894).
self.model_download_button.set_visible(not is_downloaded)
target = recommended
message = None
@@ -9155,8 +9207,16 @@ class SettingsDialog(Gtk.Dialog):
self.model_info_title.show()
self.model_info_subtitle.show()
def _auto_apply_settings(self):
"""Automatically apply settings when changed."""
def _auto_apply_settings(self, allow_download: bool = False) -> None:
"""Automatically apply settings when changed.
A picker change stages a model spec; it never confirms a download.
``allow_download`` is passed only by the simple questions, whose each
answer resolves to a complete spec, so their pick keeps the existing
apply-and-download flow. Every other caller leaves a missing model
staged: the pickers keep it, the info card offers Download, and only
``apply_settings`` (that button, or Test) starts the transfer (#894).
"""
if _handlers_suppressed(self):
return
@@ -9194,6 +9254,45 @@ class SettingsDialog(Gtk.Dialog):
# Check if model needs to be downloaded
missing_model = _undownloaded_model_info(self.language, engine, model_name)
if missing_model is not None and not allow_download:
# Staging, not confirming: leave the staged spec in the pickers
# and the saved one in the config, and repaint the info card so
# its Download action offers the explicit confirmation (#894).
self._staged_model_spec = {
"engine": engine,
"model_size": model_name,
"model_variant": settings.get("model_variant", ""),
"language": self.language,
}
# Edits outside the spec are not part of the staging: apply
# and persist them so VAD, timeouts and advanced params are
# not dropped while a download is staged. Not reconfigure() —
# it cancels a buffered recording and waits on its worker
# here on the GTK thread, and every whispercpp_* kwarg forces
# a model reload. Live-set only what actually changed; the
# load-time params stay saved-only until the Download apply
# or the next start.
non_model_settings = {
key: value for key, value in settings.items() if key not in _MODEL_SPEC_KEYS
}
applied = True
for key, clamp in _LIVE_ENGINE_SETTINGS.items():
if key not in non_model_settings:
continue
try:
live_value = clamp(non_model_settings[key])
except (TypeError, ValueError) as e:
logger.error(f"Invalid {key} while staging: {e}")
applied = False
continue
if getattr(self.speech_engine, key, None) == live_value:
continue
setattr(self.speech_engine, key, live_value)
if non_model_settings and applied:
self._save_selected_settings(non_model_settings)
self._update_model_info()
return
if missing_model is not None:
if not self.speech_engine.try_begin_download():
# The tray is already downloading a model; a second download
@@ -9272,6 +9371,10 @@ class SettingsDialog(Gtk.Dialog):
# Whatever ended the modal — cancel, failure, or a path not
# covered above — the pickers must not outlive the config.
self._resync_engine_ui_if_unapplied()
# Repaint the info card too: after a success it still showed the
# pre-download render (amber size line, Download action) until
# some other change repainted it (#894 follow-up).
self._update_model_info()
return
logger.info(f"Auto-applying settings: {settings}")
@@ -9386,6 +9489,9 @@ class SettingsDialog(Gtk.Dialog):
for key, value in advanced_settings.items():
self.config_manager.set("advanced", key, value)
self.config_manager.save_settings()
if any(key in settings for key in _MODEL_SPEC_KEYS):
# A real spec just persisted, so nothing is staged anymore (#894).
self._staged_model_spec = None
def get_selected_settings(self) -> dict[str, Any]:
"""Return the currently selected settings from the UI."""
@@ -9783,6 +9889,16 @@ For now, the engine has been reverted to VOSK."""
self._populate_model_options()
self._update_engine_specific_ui()
def _on_download_model_clicked(self, _button: Any) -> None:
"""Confirm the staged model spec and start its download (#894).
The pickers only stage a specification; this is the one explicit
confirmation that turns it into a transfer. It goes through
``apply_settings`` so the engine claim, the modal progress dialog, and
the post-download apply behave exactly like any other confirmed apply.
"""
self.apply_settings()
def apply_settings(self) -> bool:
"""Apply the selected settings."""
if self._applying_settings:
@@ -9866,6 +9982,9 @@ For now, the engine has been reverted to VOSK."""
self._populate_model_options()
self._resync_engine_ui_if_unapplied()
# Same repaint as the auto-apply path: a finished download must not
# leave the card offering a Download for the model just fetched.
self._update_model_info()
return True
return self._apply_settings_internal(settings)
+175 -5
View File
@@ -67,6 +67,9 @@ def _dialog_stub() -> Mock:
dialog.language = "en-us"
dialog._last_non_parakeet_language = None
dialog._engine_for_language_memory = None
# A Mock attribute is not a dict, which keeps the staged-spec lookups on the
# saved-model fallback; tests that exercise staging set this themselves.
dialog._staged_model_spec = None
# The real attribute is an enum member; a bare "idle" string would compare
# unequal and send every test down the stop_recognition + sleep(0.5) branch.
dialog.speech_engine.state = RecognitionState.IDLE
@@ -423,7 +426,7 @@ def test_download_path_resyncs_when_the_apply_reports_failure(settings_dialog, d
patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)),
patch.object(settings_dialog.threading, "Thread", _InlineThread),
):
dialog_class._auto_apply_settings(dialog)
dialog_class._auto_apply_settings(dialog, allow_download=True)
modal = modal_class.return_value
scheduled = [(func, args) for func, args in idle_calls]
@@ -450,7 +453,7 @@ def test_download_path_resyncs_when_the_download_is_cancelled(settings_dialog, d
patch.object(settings_dialog, "GLib", _glib_stub(idle_calls)),
patch.object(settings_dialog.threading, "Thread", _InlineThread),
):
dialog_class._auto_apply_settings(dialog)
dialog_class._auto_apply_settings(dialog, allow_download=True)
modal = modal_class.return_value
assert (dialog._idle_resync_model_ui_from_config, ()) in idle_calls
@@ -473,7 +476,7 @@ def test_modal_close_resyncs_an_engine_that_never_applied(settings_dialog, dialo
patch.object(settings_dialog, "GLib", MagicMock()),
patch.object(settings_dialog.threading, "Thread", _InlineThread),
):
dialog_class._auto_apply_settings(dialog)
dialog_class._auto_apply_settings(dialog, allow_download=True)
dialog._resync_engine_ui_if_unapplied.assert_called_once()
@@ -761,7 +764,10 @@ def test_settings_refuses_a_download_while_the_tray_holds_the_engine(
patch.object(settings_dialog, "GLib", MagicMock()),
patch.object(settings_dialog.threading, "Thread", _InlineThread),
):
getattr(dialog_class, entry)(dialog)
if entry == "_auto_apply_settings":
dialog_class._auto_apply_settings(dialog, allow_download=True)
else:
dialog_class.apply_settings(dialog)
modal_class.assert_not_called()
dialog._apply_settings_internal.assert_not_called()
@@ -781,12 +787,176 @@ def test_settings_releases_the_engine_once_the_download_is_over(
patch.object(settings_dialog, "GLib", MagicMock()),
patch.object(settings_dialog.threading, "Thread", _InlineThread),
):
getattr(dialog_class, entry)(dialog)
if entry == "_auto_apply_settings":
dialog_class._auto_apply_settings(dialog, allow_download=True)
else:
dialog_class.apply_settings(dialog)
dialog.speech_engine.try_begin_download.assert_called_once_with()
dialog.speech_engine.end_download.assert_called_once_with()
def test_a_picker_change_only_stages_a_missing_model(
settings_dialog: Any, dialog_class: type[Any]
) -> None:
"""#894: a spec that is not on disk must not start downloading on its own.
Pickers stage the spec; nothing downloads and nothing is resynced away, so
the next pick keeps composing instead of fighting a modal. Edits outside
the spec are not part of it and still apply.
"""
dialog = _dialog_stub()
_download_setup(dialog)
dialog.get_selected_settings.return_value["vad_sensitivity"] = 2
with (
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
patch.object(settings_dialog, "ModelDownloadDialog") as modal_class,
patch.object(settings_dialog, "GLib", MagicMock()),
patch.object(settings_dialog.threading, "Thread", _InlineThread),
):
dialog_class._auto_apply_settings(dialog)
modal_class.assert_not_called()
dialog.speech_engine.try_begin_download.assert_not_called()
# The spec keys are staged, not applied; edits outside it reach the
# engine live and save — never through reconfigure, which would cancel
# a buffered recording and reload the model on whispercpp_* keys.
dialog._apply_settings_internal.assert_not_called()
dialog.speech_engine.reconfigure.assert_not_called()
assert dialog.speech_engine.vad_sensitivity == 2
dialog._save_selected_settings.assert_called_once_with({"vad_sensitivity": 2})
assert dialog._staged_model_spec == {
"engine": "whisper_cpp",
"model_size": "small",
"model_variant": "",
"language": "en-us",
}
dialog._resync_model_ui_from_config.assert_not_called()
dialog._resync_engine_ui_if_unapplied.assert_not_called()
dialog._update_model_info.assert_called_once_with()
def test_staging_several_specs_still_downloads_none(
settings_dialog: Any, dialog_class: type[Any]
) -> None:
"""Each field of the spec can flip without a single download starting."""
dialog = _dialog_stub()
_download_setup(dialog)
with (
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
patch.object(settings_dialog, "ModelDownloadDialog") as modal_class,
patch.object(settings_dialog, "GLib", MagicMock()),
patch.object(settings_dialog.threading, "Thread", _InlineThread),
):
for _flip in range(3):
dialog_class._auto_apply_settings(dialog)
modal_class.assert_not_called()
dialog.speech_engine.try_begin_download.assert_not_called()
dialog._apply_settings_internal.assert_not_called()
def test_a_download_allowed_apply_still_downloads(
settings_dialog: Any, dialog_class: type[Any]
) -> None:
"""The simple questions keep the instant apply-and-download flow."""
dialog = _dialog_stub()
_download_setup(dialog)
with (
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
patch.object(settings_dialog, "ModelDownloadDialog") as modal_class,
patch.object(settings_dialog, "GLib", MagicMock()),
patch.object(settings_dialog.threading, "Thread", _InlineThread),
):
dialog_class._auto_apply_settings(dialog, allow_download=True)
dialog.speech_engine.try_begin_download.assert_called_once_with()
modal_class.assert_called_once()
def test_the_download_button_confirms_the_staged_spec(dialog_class: type[Any]) -> None:
"""The info card's Download action is the one explicit confirmation."""
dialog = _dialog_stub()
dialog_class._on_download_model_clicked(dialog, None)
dialog.apply_settings.assert_called_once_with()
@pytest.mark.parametrize("entry", ["_auto_apply_settings", "apply_settings"])
def test_a_finished_download_repaints_the_model_info_card(
settings_dialog: Any, dialog_class: type[Any], entry: str
) -> None:
"""A completed download must not leave the card on the pre-download render.
Without the repaint, the card keeps the amber size line and, since #894,
a Download action for the model that was just fetched. The cancel path
already repaints via ``_idle_resync_model_ui_from_config``; success needs
its own.
"""
dialog = _dialog_stub()
_download_setup(dialog)
with (
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
patch.object(settings_dialog, "ModelDownloadDialog"),
patch.object(settings_dialog, "GLib", MagicMock()),
patch.object(settings_dialog.threading, "Thread", _InlineThread),
):
if entry == "_auto_apply_settings":
dialog_class._auto_apply_settings(dialog, allow_download=True)
else:
dialog_class.apply_settings(dialog)
dialog._update_model_info.assert_called_once()
def _update_model_info_stub(dialog_class: type[Any]) -> Mock:
"""Enough widget state for the real ``_update_model_info`` on whisper.cpp."""
dialog = _dialog_stub()
_bind_real(dialog, dialog_class, "_update_model_info")
dialog.engine_combo.get_active_text.return_value = "whisper.cpp"
dialog._get_selected_whispercpp_model.return_value = "small"
dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "fits")
dialog._downloaded_alternative_for.return_value = None
return dialog
def test_the_info_card_offers_download_for_a_missing_model(
settings_dialog: Any, dialog_class: type[Any]
) -> None:
"""An undownloaded spec shows the confirm action on the info card."""
dialog = _update_model_info_stub(dialog_class)
with (
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=False),
patch.object(settings_dialog, "detect_compute_backend", return_value=("cpu", {})),
patch.object(settings_dialog, "get_backend_display_name", return_value="CPU"),
):
dialog._update_model_info()
dialog.model_download_button.set_visible.assert_called_once_with(True)
def test_the_info_card_offers_no_download_for_a_downloaded_model(
settings_dialog: Any, dialog_class: type[Any]
) -> None:
"""An already-downloaded spec never prompts (#894 acceptance)."""
dialog = _update_model_info_stub(dialog_class)
with (
patch.object(settings_dialog, "is_whispercpp_model_downloaded", return_value=True),
patch.object(settings_dialog, "detect_compute_backend", return_value=("cpu", {})),
patch.object(settings_dialog, "get_backend_display_name", return_value="CPU"),
):
dialog._update_model_info()
dialog.model_download_button.set_visible.assert_called_once_with(False)
def _dialog_for_engine_ui(engine_text: str):
"""Stub widgets that `_update_engine_specific_ui` show/hides."""
dialog = _dialog_stub()