diff --git a/dikte/cli.py b/dikte/cli.py index 4a06b17..7a8957c 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -945,7 +945,7 @@ def cmd_doctor(opts): # Recording, the device list, and KDE's shortcut registry. wanted += ["pw-record", "pactl", "kwriteconfig6"] wanted += ["ffmpeg", - assistant.executable(assistant.provider(conf)) or "claude", + assistant.executable(assistant.provider(conf)), cleanup.executable(cleanup.provider(conf))] programs = {name: shutil.which(name) or "" for name in wanted if name} target = conf.transcribe_target() diff --git a/dikte/hotkey.py b/dikte/hotkey.py index 91e4c6b..cc793e8 100644 --- a/dikte/hotkey.py +++ b/dikte/hotkey.py @@ -57,7 +57,7 @@ SHORTCUTS = { "Dikte: pause/resume the recording", "pause_shortcut", ""), "cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording", "cancel_shortcut", ""), - "ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code", + "ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask the agent", "assistant_shortcut", ""), "meeting": Shortcut("meeting", MEETING_DESKTOP_ID, "Dikte: start/end a meeting recording", diff --git a/dikte/i18n.py b/dikte/i18n.py index 777fe23..b0b9ebf 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -819,6 +819,7 @@ TR = { "İşlemciye yüklendi: ekran kartı açık, ama kullanılamadı.", "Not installed.": "Kurulu değil.", "Installed on the system: {path}": "Sistemde kurulu: {path}", + "Using custom build: {path}": "Özel derleme kullanılıyor: {path}", "Download again": "Yeniden indir", "Downloaded, version {version}.": "İndirildi, sürüm {version}.", "Downloaded, version {version}. There was no Vulkan build, " diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index f9cab12..0603aa5 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -223,7 +223,9 @@ class WheelGuard(QObject): """ def eventFilter(self, box, event): - if event.type() == QEvent.Type.Wheel and not box.hasFocus(): + win = box.window() + focused = box.hasFocus() or (win is not None and win.focusWidget() is box) + if event.type() == QEvent.Type.Wheel and not focused: # Refused rather than swallowed. An unaccepted wheel event carries # on up the parents to the scroll area, so the page still moves. event.ignore() @@ -253,9 +255,11 @@ class LocalModelBox(QGroupBox): changed = pyqtSignal() - def __init__(self, program, title, models, model_path, repos=None, parent=None): + def __init__(self, program, title, models, model_path, binary=None, + repos=None, parent=None): super().__init__(title, parent) self.program = program + self._binary = binary # () -> a path set by hand, or "" self._models = models # () -> [hub.Item], or (repo) -> [hub.Item] self._model_path = model_path # (name) -> Path self._repos = repos # None, or () -> [repo id] @@ -419,13 +423,23 @@ class LocalModelBox(QGroupBox): self._fill_repos(self.repository()) self._fetch_models(self.repository()) + def _program_path(self): + return ggml.program_path(self.program, + self._binary() if self._binary else "") + def _show_program(self): - path = ggml.program_path(self.program) + path = self._program_path() if not path: self.program_label.setText(t("Not installed.")) self.install_button.setText(t("Download")) self.install_button.setVisible(True) return + if self._binary and self._binary(): + # Neither a system copy nor one Dikte fetched, and "Downloaded" + # over a build someone made themselves is not true. + self.program_label.setText(t("Using custom build: {path}", path=path)) + self.install_button.setVisible(False) + return # A copy that is here is not a copy that is right. whisper.cpp releases # every few weeks, and a graphics card installed after Dikte was # changes which build this machine should be running; the button was @@ -837,7 +851,7 @@ class LocalModelBox(QGroupBox): repo=self.repository(), cap=ggml.human_size(ggml.GGUF_MAX_BYTES))) elif not name: self.status.setText(t("Nothing downloaded yet.")) - elif here and not ggml.program_path(self.program): + elif here and not self._program_path(): # The model alone runs nothing, and "Ready" over a missing program # reads as though it does. self.status.setText(t("{name} is here, but the program above is " @@ -1248,7 +1262,8 @@ class SettingsWindow(QDialog): self.local_whisper = LocalModelBox( ggml.WHISPER, t("On this machine"), - ggml.whisper_models, ggml.whisper_model_path) + ggml.whisper_models, ggml.whisper_model_path, + binary=lambda: self.conf["local_binary"]) stt_form.addRow(self.local_whisper) self.local_gpu = QCheckBox(t("Use the graphics card")) @@ -1365,7 +1380,9 @@ class SettingsWindow(QDialog): self.local_llm = LocalModelBox( ggml.LLAMA, t("On this machine"), - ggml.llm_quants, ggml.llm_model_path, repos=ggml.llm_repos) + ggml.llm_quants, ggml.llm_model_path, + binary=lambda: self.conf["local_llm_binary"], + repos=ggml.llm_repos) orr_form.addRow(self.local_llm) self.local_llm_gpu = QCheckBox(t("Use the graphics card")) diff --git a/tests/test_cli.py b/tests/test_cli.py index 92246ea..cd954f9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -578,6 +578,21 @@ class Doctor(DikteTest): self.run_doctor(as_json=False, cleanup_provider="codex", cleanup_codex_model="gpt-5.4")) + def test_agent_on_hosted_provider_does_not_ask_for_a_cli_program(self): + for provider in ("openrouter", "opencode"): + with self.subTest(provider=provider): + reply = self.run_doctor(assistant_provider=provider) + self.assertEqual(reply["agent"]["provider"], provider) + for cli_name in ("claude", "codex", "agy"): + self.assertNotIn(cli_name, reply["programs"]) + + def test_agent_on_a_cli_asks_for_the_program(self): + for provider, binary in (("claude", "claude"), ("codex", "codex"), ("agy", "agy")): + with self.subTest(provider=provider): + reply = self.run_doctor(assistant_provider=provider) + self.assertEqual(reply["agent"]["provider"], provider) + self.assertIn(binary, reply["programs"]) + class Devices(DikteTest): def test_a_machine_with_nothing_names_its_own_missing_program(self): diff --git a/tests/test_ui.py b/tests/test_ui.py index dd0513e..2f0cf01 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -16,7 +16,7 @@ from unittest import mock from PyQt6.QtCore import QPoint, QPointF, QRect, Qt from PyQt6.QtGui import QHideEvent, QShowEvent, QWheelEvent -from PyQt6.QtWidgets import QApplication, QMessageBox +from PyQt6.QtWidgets import QApplication, QComboBox, QMessageBox, QSpinBox, QWidget from dikte import audio from dikte import cleanup @@ -230,6 +230,51 @@ class Settings(DikteTest): QApplication.sendEvent(box, self.wheel()) self.assertNotEqual(box.currentIndex(), before) + def test_the_wheel_uses_remembered_focus_in_an_inactive_window(self): + # Keep the window hidden so no desktop activation policy can give it + # keyboard focus. Its remembered focus still selects the wheel target. + for widget_type in (QComboBox, QSpinBox): + with self.subTest(widget=widget_type.__name__): + window = QWidget() + self.addCleanup(window.deleteLater) + box = widget_type(window) + other = QComboBox(window) + if isinstance(box, QComboBox): + box.addItems(["first", "second", "third"]) + box.setCurrentIndex(1) + value = box.currentIndex + else: + box.setValue(5) + value = box.value + box.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + guard = settings_ui.WheelGuard(window) + box.installEventFilter(guard) + box.setFocus() + self.assertFalse(window.isActiveWindow()) + self.assertFalse(box.hasFocus()) + self.assertIs(window.focusWidget(), box) + before = value() + QApplication.sendEvent(box, self.wheel()) + self.assertNotEqual(value(), before) + other.setFocus() + self.assertIs(window.focusWidget(), other) + before = value() + rolled = self.wheel() + QApplication.sendEvent(box, rolled) + self.assertEqual(value(), before) + self.assertFalse(rolled.isAccepted()) + + def test_the_wheel_is_refused_when_another_widget_has_focus(self): + window = self.window(cfg.Config()) + box = window.ui_language + other = window.corner + other.setFocus() + before = box.currentIndex() + rolled = self.wheel() + QApplication.sendEvent(box, rolled) + self.assertEqual(box.currentIndex(), before) + self.assertFalse(rolled.isAccepted()) + def test_a_wrapped_label_keeps_the_room_its_lines_need(self): # The program path shares a row with a button, and a row is measured # before its width is known: the label has to claim the second line back @@ -1578,6 +1623,29 @@ class LocalModels(DikteTest): self.assertNotIn("Ready", box.status.text()) self.assertIn("program", box.status.text()) + def test_a_program_set_in_the_settings_is_not_called_downloaded(self): + mine = self.path("my-whisper-server") + mine.write_text("#!/bin/sh\n") + mine.chmod(0o755) + self.patch_attr(ggml.shutil, "which", lambda name: None) + box = self.window(self.config(local_binary=str(mine))).local_whisper + self.assertIn(str(mine), box.program_label.text()) + self.assertFalse(box.install_button.isVisibleTo(box)) + + def test_a_model_over_a_program_set_by_hand_is_ready(self): + # The program is there, it is just named by the settings rather than + # downloaded, and the status line looked past it. + mine = self.path("my-whisper-server") + mine.write_text("#!/bin/sh\n") + mine.chmod(0o755) + self.patch_attr(ggml.shutil, "which", lambda name: None) + path = ggml.whisper_model_path("ggml-small.bin") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"not really a model") + box = self.window(self.config(local_binary=str(mine))).local_whisper + box.load("ggml-small.bin") + self.assertIn("Ready", box.status.text()) + def test_changing_the_publisher_changes_the_model(self): # The model chosen under the old publisher is not published by the new # one. Carried over, it was added back as "not downloaded" and selected