diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5894a68..88c772d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -79,10 +79,10 @@ next port breaks all of them.
## What a pull request should carry
-A change to behaviour comes with a test for it. Adding a provider means a test
-that the request goes to the right URL with the right fields; adding a platform
-means a test for whatever the parsing of its device list, clipboard or shortcuts
-looks like. Adding a setting means both halves of `settings_ui.py`: the round
+A change to behaviour comes with a test for it. Adding a provider means a row in
+`config.TRANSCRIBERS` and a test that the request goes to the right URL with the
+right fields; adding a platform means a test for whatever the parsing of its
+device list, clipboard or shortcuts looks like. Adding a setting means both halves of `settings_ui.py`: the round
trip in `tests/test_ui.py` is what catches only one of them being written.
Match the surrounding code: it is plain Python with no framework, comments
diff --git a/README.md b/README.md
index bd27986..7f45e4d 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ just the Python standard library and PyQt6.
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
systemctl --user enable --now ydotool # needed for auto-paste
-./install.sh # or: ./install.sh "Ctrl+Alt+Space"
+./install.sh # or: ./install.sh "Meta+Space" "Meta+Shift+Space"
dikte # the settings window opens on first run
```
@@ -36,15 +36,19 @@ tools instead:
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
```
-`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The
-settings window installs a GNOME or KDE global shortcut.
+`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the
+two global shortcuts, whose keys are its two arguments. `./update.sh` pulls and
+puts all of that back, keeping the keys you chose; `./uninstall.sh` takes it away
+again and leaves your settings and dictations alone unless you pass `--purge`.
-Speech to text and cleanup each pick a provider in the settings window. Both can
-run here, on whisper.cpp and llama.cpp: the program and the model are downloaded
-from that window (checksummed, into `~/.local/share/dikte`), so nothing has to be
-installed first and nothing leaves the machine. The hosted alternatives want a
-key: **OpenAI** or **OpenRouter** for speech to text, **OpenRouter** for cleanup
-(`google/gemini-3.5-flash-lite`), so a single OpenRouter key can cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are
+Speech to text and cleanup each pick a provider in the settings window, and both
+can run here, on whisper.cpp and llama.cpp: the program and the model are
+downloaded from that window, checksummed, so nothing has to be installed first
+and nothing leaves the machine. Otherwise speech to text runs on **OpenAI**,
+**Groq** or **OpenRouter** (`gpt-4o-transcribe` by default) and cleanup on
+OpenRouter (`google/gemini-3.5-flash-lite`) or, when either is installed, on
+Claude Code or Codex. The keys fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and
+`OPENROUTER_API_KEY`, and are
stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off,
in which case the raw transcript is pasted, and a thinking model's effort can be
set next to it.
@@ -54,7 +58,7 @@ set next to it.
| What | How |
| --- | --- |
| Start / stop recording | `Ctrl+Space`, or click the tray icon |
-| Cancel a recording | Tray menu → *Cancel recording*, or `dikte cancel` |
+| Discard the recording | `Ctrl+Alt+Space`, tray menu, or `dikte cancel` |
| Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` |
| Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` |
| Settings | Tray menu → *Settings*, or `dikte settings` |
@@ -128,11 +132,11 @@ running.
right-click to delete.
- **Turkish and English interface**, following the system locale by default.
-## The global shortcut needs one logout
+## The global shortcuts need one logout
-KWin only reads `kglobalshortcutsrc` at startup, so the shortcut `install.sh`
+KWin only reads `kglobalshortcutsrc` at startup, so the shortcuts `install.sh`
writes will not fire until you log out and back in. Until then, Settings →
-Shortcut → **built-in listener** reads `/dev/input` and catches the combination
+Shortcuts → **built-in listener** reads `/dev/input` and catches the combination
itself. The difference: it does not swallow the key, so `Ctrl+Space` also reaches
the focused application (some editors will pop up autocomplete). The listener
needs your user in the `input` group: `sudo usermod -aG input $USER`.
@@ -146,7 +150,8 @@ ipc.py one request and one reply over the local socket
audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting
meeting.py channel split, speaker labelling, cleanup, minutes
assistant.py running a dictation through Claude Code, Codex or OpenRouter
-api.py transcription and cleanup on any provider (stdlib only)
+api.py transcription and cleanup requests (stdlib only)
+cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex
ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving
hub.py what GitHub and Hugging Face have on offer today
worker.py transcribe → clean up → clipboard → paste
diff --git a/README.tr.md b/README.tr.md
index 454ec6a..f60087b 100644
--- a/README.tr.md
+++ b/README.tr.md
@@ -24,7 +24,7 @@ sadece Python standart kütüphanesi ve PyQt6.
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
systemctl --user enable --now ydotool # otomatik yapıştırma için
-./install.sh # ya da: ./install.sh "Ctrl+Alt+Space"
+./install.sh # ya da: ./install.sh "Meta+Space" "Meta+Shift+Space"
dikte # ilk açılışta ayarlar penceresi gelir
```
@@ -35,17 +35,20 @@ araçlarıyla çalışır:
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
```
-`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik
-başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
+`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
+başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı. `./update.sh`
+son sürümü çeker ve bunları senin seçtiğin tuşlarla yerine koyar;
+`./uninstall.sh` hepsini geri alır, `--purge` demedikçe ayarlarına ve
+diktelerine dokunmaz.
Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı
-seçer. İkisi de burada çalışabilir, whisper.cpp ve llama.cpp üzerinde: program
-da model de o pencereden indirilir (sha256 doğrulamasıyla,
-`~/.local/share/dikte` altına), yani önceden hiçbir şey kurman gerekmez ve
-makineden hiçbir şey çıkmaz. Bulut seçenekleri anahtar ister: yazıya çevirme
-için **OpenAI** ya da **OpenRouter**, temizleme için **OpenRouter**
-(`google/gemini-3.5-flash-lite`), yani tek bir OpenRouter anahtarı ikisine de
-yeter. Boş bırakırsan `OPENAI_API_KEY` ve
+seçer; ikisi de burada çalışabilir, whisper.cpp ve llama.cpp üzerinde: program da
+model de o pencereden, sha256 doğrulamasıyla indirilir, yani önceden hiçbir şey
+kurman gerekmez ve makineden hiçbir şey çıkmaz. Bulutu seçersen sesi yazıya
+çevirme **OpenAI**, **Groq** ya da **OpenRouter**'da (varsayılan
+`gpt-4o-transcribe`), temizleme OpenRouter'da
+(`google/gemini-3.5-flash-lite`) ya da kuruluysa Claude Code veya Codex'te
+çalışır. Anahtarları boş bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY` ve
`OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json`
içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript
yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin.
@@ -55,7 +58,7 @@ yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebili
| Ne | Nasıl |
| --- | --- |
| Kaydı başlat / bitir | `Ctrl+Space`, ya da tepsi simgesine tıkla |
-| Kaydı iptal et | Tepsi menüsü → *Kaydı iptal et*, ya da `dikte cancel` |
+| Kaydı iptal et | `Ctrl+Alt+Space`, tepsi menüsü, ya da `dikte cancel` |
| Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` |
| Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` |
| Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` |
@@ -127,11 +130,11 @@ olmasını ister.
silebilirsin.
- **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler.
-## Global kısayol için bir kez oturum kapatmak gerekir
+## Global kısayollar için bir kez oturum kapatmak gerekir
KWin `kglobalshortcutsrc` dosyasını yalnızca açılışta okur, yani `install.sh`'ın
-yazdığı kısayol oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar →
-Kısayol → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi
+yazdığı kısayollar oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar →
+Kısayollar → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi
yakalar. Tek farkı: tuşu yutmaz, yani `Ctrl+Space` odaktaki uygulamaya da iletilir
(bazı editörlerde otomatik tamamlama açılabilir). Dinleyici kullanıcının `input`
grubunda olmasını gerektirir: `sudo usermod -aG input $USER`.
@@ -145,7 +148,8 @@ ipc.py yerel sokette bir istek, bir cevap
audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg
meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak
assistant.py dikteyi Claude Code, Codex ya da OpenRouter'dan geçirme
-api.py her sağlayıcıda transkript ve temizleme (yalnız stdlib)
+api.py transkript ve temizleme istekleri (yalnız stdlib)
+cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex
ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma
hub.py GitHub ve Hugging Face'te bugün ne olduğu
worker.py transkript → temizleme → pano → yapıştırma
diff --git a/api.py b/api.py
index df9bab6..cc27e66 100644
--- a/api.py
+++ b/api.py
@@ -1,14 +1,13 @@
-"""OpenAI, OpenRouter and this machine, stdlib only.
+"""OpenAI, Groq, OpenRouter and this machine, stdlib only.
-Transcription runs on any of three providers and cleanup on two, and none of
-them needs code of its own. OpenRouter mirrors OpenAI's /audio/transcriptions
-endpoint field for field, and ggml.py starts whisper.cpp on that same path, so
-one multipart request serves all three; llama.cpp answers /chat/completions the
-way OpenRouter does, so one JSON request serves both. What changes between them
-is the key, the base URL and the model id.
+Transcription runs on any of the four: Groq and OpenRouter both mirror OpenAI's
+/audio/transcriptions endpoint field for field, and ggml.py starts whisper.cpp
+on that same path, so one multipart request serves all of them and only the key,
+the base URL and the model id change. llama.cpp answers /chat/completions the way
+OpenRouter does, so cleanup here is the same request too.
-The local ones have no key, and their base URL is not known until a server is
-up, which is the one thing this module has to fill in for them.
+What is on this machine has no key, and its base URL is not known until a server
+is up, which is the one thing this module has to fill in for it.
"""
import collections
@@ -25,6 +24,7 @@ from i18n import t
APP_URL = "https://github.com/yusufipk/dikte"
USER_AGENT = f"dikte/1.0 (+{APP_URL})"
OPENAI_URL = "https://api.openai.com/v1"
+GROQ_URL = "https://api.groq.com/openai/v1"
OPENROUTER_URL = "https://openrouter.ai/api/v1"
# The floor for a local request. The timeouts elsewhere are sized for a hosted
@@ -33,23 +33,23 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1"
# deal of it. Cutting that off would throw the work away for nothing.
LOCAL_TIMEOUT = 3600
-# Where a request goes; built by config.Config's *_target() methods. `service`
-# is the name the user sees in an error, `provider` the one the code branches
-# on. `reasoning` is only read by cleanup, which is the only job with a model
-# that might think about anything.
-Target = collections.namedtuple(
- "Target", "provider service api_key base_url model reasoning", defaults=("",))
+# Where a transcription request goes; built by config.Config.transcribe_target().
+# `service` is the name the user sees in an error, `provider` the one the code
+# branches on.
+Target = collections.namedtuple("Target", "provider service api_key base_url model")
-def timestamp_model(provider, model):
- """Only whisper-1 returns segment times, and OpenRouter namespaces the id.
+def timestamp_model(provider, selected=""):
+ """Which model answers with segment times.
- Whisper is what the local server runs whatever the file is called, so there
- it stays on the model that is already loaded; asking for another one would
- name a model that server has never heard of.
+ OpenAI keeps them to whisper-1 and OpenRouter namespaces that id. Everything
+ Groq transcribes with is a whisper, so the model already chosen does it and
+ the fallback is only for a provider left on its default. So is everything the
+ local server runs, whatever the file is called, and there asking for another
+ model would name one it has never heard of.
"""
- if provider == "local":
- return model
+ if provider in ("groq", "local"):
+ return selected or "whisper-large-v3-turbo"
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
@@ -141,34 +141,36 @@ def _headers(provider, api_key, content_type=None):
return headers
-def _serving(target, server, timeout):
- """A local target with the address of a running server in it.
+def serving(server):
+ """The base URL of a local server, started if it is not up yet.
- The server is started on demand and picks its own port, so this is the first
- moment its address exists. serve() is idempotent: once it is up this costs
- nothing.
+ It picks its own port, so this is the first moment its address exists.
+ serve() is idempotent: once it is running this costs nothing.
"""
try:
- return target._replace(base_url=server.serve()), max(timeout, LOCAL_TIMEOUT)
+ return server.serve()
except ggml.LocalError as exc:
raise ApiError(str(exc)) from None
-def _local_failure(target, server, exc):
+def local_failure(service, server, exc):
"""A server that died mid-request, explained by its own output.
Without this the message is that the connection dropped, when the reason for
it was printed by the process at the other end.
"""
detail = server.error()
- return ApiError(f"{target.service}: {exc}" + (f" ({detail})" if detail else ""),
+ return ApiError(f"{service}: {exc}" + (f" ({detail})" if detail else ""),
exc.status)
def _transcribe_request(target, wav_path, language, prompt, response_format,
granularity=None, timeout=300):
if target.provider == "local":
- target, timeout = _serving(target, ggml.whisper, timeout)
+ # The timeouts here are sized for a hosted API, where a slow answer is a
+ # bill running. Locally the only thing being spent is time.
+ target = target._replace(base_url=serving(ggml.whisper))
+ timeout = max(timeout, LOCAL_TIMEOUT)
elif not target.api_key:
raise ApiError(t("{service} API key is empty. Add it in Settings.",
service=target.service))
@@ -178,7 +180,7 @@ def _transcribe_request(target, wav_path, language, prompt, response_format,
# OpenRouter takes the hint field and throws it away, so spare it the bytes.
# The same words still reach the cleanup model as a glossary. whisper.cpp
# takes it as the initial prompt, the way OpenAI does.
- if prompt and target.provider in ("openai", "local"):
+ if prompt and target.provider != "openrouter":
fields.append(("prompt", prompt))
if granularity:
fields.append(("timestamp_granularities[]", granularity))
@@ -190,7 +192,7 @@ def _transcribe_request(target, wav_path, language, prompt, response_format,
)
except ApiError as exc:
if target.provider == "local":
- raise _local_failure(target, ggml.whisper, exc) from None
+ raise local_failure(target.service, ggml.whisper, exc) from None
raise explain(exc, target.service) from None
@@ -274,29 +276,27 @@ def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
return out
-def _thinking(target, payload):
+def _thinking(payload, provider, reasoning):
"""Ask for as much thinking as this provider understands, or for none.
An empty level means "whatever the model does on its own", so nothing is
- sent. The two providers mean opposite things by that, which is why the
- setting is kept per provider: OpenRouter's cleanup models answer straight
- away, while a local model that was trained to think will think, and cleanup
- is punctuation rather than a job worth thinking about.
+ sent. The two mean opposite things by that, which is why the setting is kept
+ per provider: OpenRouter's cleanup models answer straight away, while a local
+ model that was trained to think will think, and cleanup is punctuation rather
+ than a job worth thinking about.
"""
- if not target.reasoning:
+ if not reasoning:
return
- if target.provider == "local-llm":
- # What llama.cpp passes to the chat template. The models that think
- # read it; the ones that do not ignore it.
- payload["chat_template_kwargs"] = {
- "enable_thinking": target.reasoning != "none"}
- return
- if target.reasoning != "none":
+ if provider == "local-llm":
+ # What llama.cpp passes to the chat template. The models that think read
+ # it; the ones that do not ignore it.
+ payload["chat_template_kwargs"] = {"enable_thinking": reasoning != "none"}
+ elif reasoning != "none":
# The thinking itself is never shown, so ask for it to be left out.
- payload["reasoning"] = {"effort": target.reasoning, "exclude": True}
+ payload["reasoning"] = {"effort": reasoning, "exclude": True}
-def _local_ceiling(text):
+def local_ceiling(text):
"""How much of a reply is worth waiting for from a model on this machine.
Cleanup gives back what it was given, near enough, so a reply several times
@@ -309,34 +309,32 @@ def _local_ceiling(text):
return max(512, len(text))
-def cleanup(target, text, system_prompt, timeout=180):
- if target.provider == "local-llm":
- target, timeout = _serving(target, ggml.llm, timeout)
- elif not target.api_key:
+def cleanup(text, api_key, model, system_prompt, reasoning="",
+ base_url=OPENROUTER_URL, timeout=180, provider="openrouter",
+ service="OpenRouter"):
+ if not api_key and provider != "local-llm":
raise ApiError(t("{service} API key is empty. Add it in Settings.",
- service=target.service))
+ service=service))
payload = {
- "model": target.model,
+ "model": model,
"temperature": 0,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"\n{text}\n"},
],
}
- if target.provider == "local-llm":
- payload["max_tokens"] = _local_ceiling(text)
- _thinking(target, payload)
+ if provider == "local-llm":
+ payload["max_tokens"] = local_ceiling(text)
+ _thinking(payload, provider, reasoning)
try:
data = _request(
- f"{target.base_url.rstrip('/')}/chat/completions",
+ f"{base_url.rstrip('/')}/chat/completions",
json.dumps(payload).encode("utf-8"),
- _headers(target.provider, target.api_key, "application/json"),
+ _headers(provider, api_key, "application/json"),
timeout=timeout,
)
except ApiError as exc:
- if target.provider == "local-llm":
- raise _local_failure(target, ggml.llm, exc) from None
- raise explain(exc, target.service) from None
+ raise explain(exc, service) from None
choices = data.get("choices") or []
if not choices:
raise ApiError(_extract_error(json.dumps(data)))
@@ -348,7 +346,7 @@ def cleanup(target, text, system_prompt, timeout=180):
# than a retry: cleanup is not a job that wants thinking.
if message.get("reasoning_content") or message.get("reasoning"):
raise ApiError(t("The cleanup model spent its whole reply on "
- "thinking. Set Thinking to “Off”."))
+ "thinking. Set Thinking to \u201cOff\u201d."))
raise ApiError(t("The cleanup model returned an empty reply."))
return content
@@ -441,17 +439,22 @@ def openrouter_models(api_key="", transcription=False):
return sorted(m["id"] for m in models if m.get("id"))
-def openai_models(api_key, base_url=OPENAI_URL):
+def openai_models(api_key, base_url=OPENAI_URL, service="OpenAI"):
+ """The audio models of anything that speaks OpenAI's /models, Groq included.
+
+ `service` is only the name an error is written in, so a Groq key that is
+ refused says Groq rather than OpenAI.
+ """
if not api_key:
raise ApiError(t("{service} API key is empty. Add it in Settings.",
- service="OpenAI"))
+ service=service))
try:
data = _get_json(
f"{base_url.rstrip('/')}/models",
{"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT},
)
except ApiError as exc:
- raise explain(exc, "OpenAI") from None
+ raise explain(exc, service) from None
ids = [m["id"] for m in data.get("data", []) if m.get("id")]
audio = [i for i in ids if "transcribe" in i or "whisper" in i]
return sorted(audio or ids)
diff --git a/assistant.py b/assistant.py
index 20339bb..f708ab3 100644
--- a/assistant.py
+++ b/assistant.py
@@ -75,7 +75,10 @@ CODEX_ITEMS = {
CLAUDE_EFFORT = {"none": "low", "minimal": "low", "low": "low",
"medium": "medium", "high": "high", "xhigh": "xhigh",
"max": "max"}
-CODEX_EFFORT = {"none": "minimal", "minimal": "minimal", "low": "low",
+# "minimal" was Codex's bottom rung until the newer models replaced it with
+# "none", and each of them rejects the other's word for it with a 400. "low" is
+# the one every model has, so the two lowest rungs land there instead.
+CODEX_EFFORT = {"none": "low", "minimal": "low", "low": "low",
"medium": "medium", "high": "high", "xhigh": "high",
"max": "high"}
@@ -421,7 +424,7 @@ def _conclude(found, code, stderr, session, service):
if code != 0 and not found["answer"]:
if session and _session_missing(stderr):
raise _SessionGone()
- raise AssistantError(_last_line(stderr) or found["failure"] or t(
+ raise AssistantError(last_line(stderr) or found["failure"] or t(
"{service} exited with code {code}.", service=service, code=code))
if found["failure"] and not found["answer"]:
raise AssistantError(found["failure"])
@@ -480,6 +483,11 @@ def _finish(proc):
return stderr
-def _last_line(text):
+def last_line(text):
+ """The line worth showing out of a CLI's stderr: the last one it wrote.
+
+ Shared with cleanup, which runs the same two programs for a different job
+ and fails the same way when they are unhappy.
+ """
lines = [line for line in (text or "").splitlines() if line.strip()]
return lines[-1].strip() if lines else ""
diff --git a/cleanup.py b/cleanup.py
new file mode 100644
index 0000000..0dbb273
--- /dev/null
+++ b/cleanup.py
@@ -0,0 +1,208 @@
+"""Who rewrites the transcript once it has been heard.
+
+Normally a small model on OpenRouter: one request, a second, a few tenths of a
+cent. A machine with Claude Code or Codex on it is already paying for a model
+though, and the subscription that answers "put that in my calendar on Thursday"
+can just as well take the "eee"s out of a sentence. No second key, no second
+bill. It costs seconds rather than one, because a CLI opens a whole session to
+do it, which is the trade.
+
+Whoever does it, the job is the same one: no tools, no files, no memory of the
+last dictation. There is nothing here to look up and nothing to carry over, and
+a transcript is text from a microphone rather than an instruction, so the less
+the agent can reach while it reads one, the better.
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+
+import api
+import assistant
+import ggml
+from i18n import t
+
+PROVIDERS = ("openrouter", "local", "claude", "codex")
+
+
+class CleanupError(api.ApiError):
+ """What a CLI could not do.
+
+ An ApiError because to the chain a cleanup that failed is a cleanup that
+ failed, whichever way it was run, and every caller already catches one and
+ keeps the raw transcript.
+ """
+
+
+def provider(conf):
+ chosen = conf["cleanup_provider"]
+ return chosen if chosen in PROVIDERS else "openrouter"
+
+
+def executable(name):
+ """The CLI a provider runs, or "" when it needs none."""
+ return {"claude": "claude", "codex": "codex"}.get(name, "")
+
+
+def model(conf):
+ """Which model does the cleaning, for the history and the settings window."""
+ name = provider(conf)
+ if name == "local":
+ return conf["local_llm_model"]
+ if name == "claude":
+ return conf["cleanup_claude_model"].strip() or "haiku"
+ if name == "codex":
+ # Codex is left on whatever it is set to unless a model is typed in, so
+ # here there is only the name of the thing that did it.
+ return conf["cleanup_codex_model"].strip() or "codex"
+ return conf["cleanup_model"]
+
+
+def run(text, conf, system_prompt, timeout=180):
+ """Hand the transcript to whoever is set to clean it up."""
+ name = provider(conf)
+ if name == "openrouter":
+ return api.cleanup(
+ text, conf.openrouter_key(), conf["cleanup_model"], system_prompt,
+ reasoning=conf["cleanup_reasoning"],
+ base_url=conf["openrouter_base_url"], timeout=timeout,
+ )
+ if name == "local":
+ return _local(text, conf, system_prompt, timeout)
+ runner = _claude if name == "claude" else _codex
+ return runner(text, conf, system_prompt, timeout)
+
+
+def _local(text, conf, system_prompt, timeout):
+ """llama.cpp, on this machine, answering the request OpenRouter answers.
+
+ No key and no bill, and the address does not exist until the server is up,
+ which is what starting it here is for. The timeout is the hosted one raised:
+ the only thing being spent is time.
+ """
+ service = t("Local model")
+ try:
+ return api.cleanup(
+ text, "", conf["local_llm_model"], system_prompt,
+ reasoning=conf["local_llm_reasoning"],
+ base_url=api.serving(ggml.llm),
+ timeout=max(timeout, api.LOCAL_TIMEOUT),
+ provider="local-llm", service=service,
+ )
+ except api.ApiError as exc:
+ # A server that died mid-request would otherwise report only that the
+ # connection dropped, when the reason is in its own output.
+ raise api.local_failure(service, ggml.llm, exc) from None
+
+
+def _wrap(text):
+ """The same fence the OpenRouter call puts around it: this is the material,
+ not the instruction, however much of it reads like one."""
+ return f"\n{text}\n"
+
+
+# --- Claude Code ----------------------------------------------------------
+
+def _claude(text, conf, system_prompt, timeout):
+ cmd = [
+ "claude", "-p", _wrap(text),
+ # --system-prompt rather than --append-system-prompt: the cleanup rules
+ # are the whole job, and Claude Code's own instructions are about
+ # working on a codebase.
+ "--system-prompt", system_prompt,
+ "--model", model(conf),
+ "--output-format", "text",
+ "--tools", "", # nothing to run
+ "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
+ "--no-session-persistence", # nothing to resume
+ ]
+ effort = assistant.CLAUDE_EFFORT.get(conf["cleanup_reasoning"], "")
+ if effort:
+ cmd += ["--effort", effort]
+
+ answer = _output(cmd, timeout, "Claude")
+ if not answer:
+ raise CleanupError(t("{service} answered with nothing.", service="Claude"))
+ return answer
+
+
+# --- Codex ----------------------------------------------------------------
+
+def _codex(text, conf, system_prompt, timeout):
+ # Codex takes no system prompt of its own, so the rules ride in front of the
+ # transcript, kept apart from it so the two are not read as one.
+ body = f"{system_prompt}\n\n---\n\n{_wrap(text)}"
+ cmd = [
+ "codex", "exec",
+ "--sandbox", "read-only", # it has no reason to touch the disk
+ "--skip-git-repo-check",
+ "--ephemeral", # nothing to resume
+ "--color", "never",
+ "-c", 'approval_policy="never"', # there is nobody here to approve
+ ]
+ if conf["cleanup_codex_model"].strip():
+ cmd += ["-m", conf["cleanup_codex_model"].strip()]
+ effort = assistant.CODEX_EFFORT.get(conf["cleanup_reasoning"], "")
+ if effort:
+ cmd += ["-c", f'model_reasoning_effort="{effort}"']
+
+ # `codex exec` prints a header, its thinking and a token count around the
+ # answer; the file it writes on the way out is the answer on its own.
+ handle, last_message = tempfile.mkstemp(prefix="dikte-cleanup-", suffix=".txt")
+ os.close(handle)
+ cmd += ["-o", last_message, body]
+ try:
+ _output(cmd, timeout, "Codex")
+ answer = _read(last_message)
+ finally:
+ try:
+ os.unlink(last_message)
+ except OSError:
+ pass
+
+ if not answer:
+ raise CleanupError(t("{service} answered with nothing.", service="Codex"))
+ return answer
+
+
+def _read(path):
+ try:
+ with open(path, encoding="utf-8", errors="replace") as fh:
+ return fh.read().strip()
+ except OSError:
+ return ""
+
+
+# --- running a CLI --------------------------------------------------------
+
+def _output(cmd, timeout, service):
+ """Run cmd to the end and return what it printed.
+
+ It runs in the home directory rather than wherever the agent is pointed: a
+ project's instructions have opinions about how text should be written, and
+ none of them are about this transcript.
+ """
+ binary = cmd[0]
+ if not shutil.which(binary):
+ raise CleanupError(t(
+ "{binary} not found. Install it, or have OpenRouter clean up "
+ "instead, under Settings → API and models.", binary=binary,
+ ))
+ try:
+ done = subprocess.run(
+ cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL,
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
+ timeout=timeout,
+ )
+ except subprocess.TimeoutExpired:
+ raise CleanupError(t("{service} did not finish within {seconds} seconds.",
+ service=service, seconds=timeout)) from None
+ except OSError as exc:
+ raise CleanupError(t("Could not run {binary}: {error}",
+ binary=binary, error=exc)) from exc
+ if done.returncode != 0:
+ raise CleanupError(assistant.last_line(done.stderr) or t(
+ "{service} exited with code {code}.",
+ service=service, code=done.returncode))
+ return (done.stdout or "").strip()
diff --git a/cli.py b/cli.py
index facb667..334a957 100644
--- a/cli.py
+++ b/cli.py
@@ -25,6 +25,7 @@ from PyQt6.QtCore import QCoreApplication, QTimer
import api
import assistant
import audio
+import cleanup
import config as cfg
import filetranscribe
import hotkey
@@ -44,14 +45,6 @@ GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"}
IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel",
"ask-reset", "meeting-cancel"}
-# Which desktop entry, name and setting belong to each of the three shortcuts.
-SHORTCUTS = {
- "toggle": (hotkey.DESKTOP_ID, "Dikte: start/stop recording", "shortcut"),
- "ask": (hotkey.ASK_DESKTOP_ID, "Dikte: ask Claude Code", "assistant_shortcut"),
- "meeting": (hotkey.MEETING_DESKTOP_ID, "Dikte: start/end a meeting recording",
- "meeting_shortcut"),
-}
-
_app = None
@@ -662,12 +655,14 @@ def cmd_devices(opts):
def cmd_models(opts):
conf = cfg.Config()
+ who = cfg.TRANSCRIBERS[opts.provider]
try:
- if opts.provider == "openai":
- models = api.openai_models(conf.openai_key(), conf["openai_base_url"])
- else:
+ if opts.provider == "openrouter":
models = api.openrouter_models(conf.openrouter_key(),
transcription=opts.transcription)
+ else:
+ models = api.openai_models(conf.api_key(who.key), conf[who.url],
+ who.service)
except api.ApiError as exc:
return fail(opts, exc)
return out(opts, {"ok": True, "provider": opts.provider, "models": models},
@@ -677,19 +672,21 @@ def cmd_models(opts):
def cmd_test_key(opts):
conf = cfg.Config()
results = {}
- if opts.which in ("openai", "all"):
+ for name, who in cfg.TRANSCRIBERS.items():
+ if opts.which not in (name, "all"):
+ continue
try:
- count = len(api.openai_models(conf.openai_key(), conf["openai_base_url"]))
- results["openai"] = {"ok": True,
- "message": f"connection works, {count} models visible"}
+ if name == "openrouter":
+ # The one key that also pays for cleanup, so it reports credit
+ # rather than a model count.
+ message = api.openrouter_key_status(conf.openrouter_key())
+ else:
+ count = len(api.openai_models(conf.api_key(who.key), conf[who.url],
+ who.service))
+ message = f"connection works, {count} models visible"
+ results[name] = {"ok": True, "message": message}
except api.ApiError as exc:
- results["openai"] = {"ok": False, "message": str(exc)}
- if opts.which in ("openrouter", "all"):
- try:
- results["openrouter"] = {"ok": True,
- "message": api.openrouter_key_status(conf.openrouter_key())}
- except api.ApiError as exc:
- results["openrouter"] = {"ok": False, "message": str(exc)}
+ results[name] = {"ok": False, "message": str(exc)}
everything_ok = all(item["ok"] for item in results.values())
lines = [f"{'✓' if item['ok'] else '✗'} {name}: {item['message']}"
for name, item in results.items()]
@@ -701,9 +698,9 @@ def cmd_shortcut(opts):
conf = cfg.Config()
if opts.shortcut == "status":
rows = {}
- for name, (desktop_id, _label, key) in SHORTCUTS.items():
- rows[name] = {"registered": hotkey.shortcut_status(desktop_id),
- "configured": conf[key]}
+ for name, spec in hotkey.SHORTCUTS.items():
+ rows[name] = {"registered": hotkey.shortcut_status(spec.desktop_id),
+ "configured": conf[spec.setting]}
lines = [f"{name:8} {row['registered'] or '(not installed)':16} "
f"setting: {row['configured'] or '(none)'}"
for name, row in rows.items()]
@@ -711,28 +708,29 @@ def cmd_shortcut(opts):
return out(opts, {"ok": True, "shortcuts": rows,
"listener": conf["evdev_hotkey"]}, "\n".join(lines))
- desktop_id, label, key = SHORTCUTS[opts.which]
+ spec = hotkey.SHORTCUTS[opts.which]
if opts.shortcut == "remove":
- hotkey.remove_shortcut(desktop_id)
+ hotkey.remove_shortcut(spec.desktop_id)
return out(opts, {"ok": True, "removed": opts.which},
f"Removed the {opts.which} shortcut.")
- combo = (opts.combo or conf[key] or ("Ctrl+Space" if opts.which == "toggle" else "")).strip()
+ combo = (opts.combo or conf[spec.setting] or spec.fallback).strip()
if not combo:
return fail(opts, "no combination given and none stored; pass --combo", 2)
if hotkey.parse_shortcut(combo) == (None, None):
return fail(opts, f"cannot parse that combination: {combo}", 2)
- clashes = hotkey.conflicting_shortcuts(combo, desktop_id)
+ clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id)
if clashes and not opts.force:
return fail(opts, f"{combo} is also used by: {', '.join(clashes[:6])}. "
"Pass --force to install it anyway.", 1, conflicts=clashes)
ok, message = hotkey.install_shortcut(
- combo, ipc.command_for(opts.which), name=label, desktop_id=desktop_id,
+ combo, ipc.command_for(spec.verb), name=spec.name,
+ desktop_id=spec.desktop_id,
)
if not ok:
return fail(opts, message)
- conf[key] = combo
+ conf[spec.setting] = combo
try:
conf.save()
except OSError as exc:
@@ -767,16 +765,18 @@ def cmd_status(opts):
def cmd_doctor(opts):
"""What the settings window checks behind its buttons, in one pass."""
conf = cfg.Config()
- programs = {name: shutil.which(name) or ""
- for name in ("pw-record", "wl-copy", "ydotool", "ffmpeg",
- "pactl", "kwriteconfig6",
- assistant.executable(assistant.provider(conf)) or "claude")}
+ wanted = ["pw-record", "wl-copy", "ydotool", "ffmpeg", "pactl", "kwriteconfig6",
+ assistant.executable(assistant.provider(conf)) or "claude",
+ cleanup.executable(cleanup.provider(conf))]
+ programs = {name: shutil.which(name) or "" for name in wanted if name}
target = conf.transcribe_target()
+ cleaner = cleanup.provider(conf)
checks = {
"programs": programs,
"transcription": {"provider": target.provider, "model": target.model,
"key": bool(target.api_key)},
- "cleanup": {"enabled": conf["cleanup_enabled"], "model": conf["cleanup_model"],
+ "cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner,
+ "model": cleanup.model(conf),
"key": bool(conf.openrouter_key())},
"agent": {"provider": assistant.provider(conf),
"directory": assistant.working_dir(conf)},
@@ -787,8 +787,11 @@ def cmd_doctor(opts):
lines += [
f"{'✓' if target.api_key else '✗'} {target.service} key, transcribing on "
f"{target.model}",
- f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on "
- f"{conf['cleanup_model']}",
+ # Cleanup on a CLI needs no key, so what is checked is the program.
+ (f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on "
+ f"{conf['cleanup_model']}") if cleaner == "openrouter" else
+ (f"{'✓' if programs[cleanup.executable(cleaner)] else '✗'} "
+ f"{cleanup.executable(cleaner)}, cleaning up on {cleanup.model(conf)}"),
f"{'✓' if checks['running'] else '·'} application "
+ ("running" if checks["running"] else "not running"),
]
@@ -986,30 +989,31 @@ def build_parser():
# --- the machine ------------------------------------------------------
leaf(subs, "devices", "microphones and monitors").set_defaults(func=cmd_devices)
models = leaf(subs, "models", "model ids a provider offers")
- models.add_argument("--provider", choices=("openrouter", "openai"),
+ models.add_argument("--provider", choices=tuple(cfg.TRANSCRIBERS),
default="openrouter")
models.add_argument("--transcription", action="store_true",
help="only the speech-to-text ones")
models.set_defaults(func=cmd_models)
test = leaf(subs, "test-key", "check the API keys")
test.add_argument("which", nargs="?", default="all",
- choices=("all", "openai", "openrouter"))
+ choices=("all", *cfg.TRANSCRIBERS))
test.set_defaults(func=cmd_test_key)
leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor)
- shortcut = leaf(subs, "shortcut", "the KDE global shortcuts")
+ shortcut = leaf(subs, "shortcut", "the desktop's global shortcuts")
inner = shortcut.add_subparsers(dest="shortcut", metavar="")
shortcut.set_defaults(func=_needs_subcommand(shortcut))
leaf(inner, "status", "what is registered").set_defaults(func=cmd_shortcut)
install = leaf(inner, "install", "register one")
install.add_argument("which", nargs="?", default="toggle",
- choices=tuple(SHORTCUTS))
+ choices=tuple(hotkey.SHORTCUTS))
install.add_argument("--combo", help="e.g. Ctrl+Alt+Space")
install.add_argument("--force", action="store_true",
help="install it even if something else uses it")
install.set_defaults(func=cmd_shortcut)
remove = leaf(inner, "remove", "unregister one")
- remove.add_argument("which", nargs="?", default="toggle", choices=tuple(SHORTCUTS))
+ remove.add_argument("which", nargs="?", default="toggle",
+ choices=tuple(hotkey.SHORTCUTS))
remove.set_defaults(func=cmd_shortcut)
# --- the application --------------------------------------------------
diff --git a/config.py b/config.py
index 16d9c0d..0f1c48f 100644
--- a/config.py
+++ b/config.py
@@ -1,5 +1,6 @@
"""Settings storage in ~/.config/dikte/config.json"""
+import collections
import hashlib
import json
import os
@@ -365,10 +366,13 @@ DEFAULTS = {
"ui_language": "auto", # auto | tr | en
"openai_api_key": "",
"openai_base_url": "https://api.openai.com/v1",
+ "groq_api_key": "",
+ "groq_base_url": "https://api.groq.com/openai/v1",
"openrouter_api_key": "",
"openrouter_base_url": "https://openrouter.ai/api/v1",
- "transcribe_provider": "local", # local | openai | openrouter
+ "transcribe_provider": "local", # "local", or a key of TRANSCRIBERS
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
+ "groq_transcribe_model": "whisper-large-v3-turbo",
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
"language": "tr",
"transcribe_prompt": "",
@@ -387,8 +391,10 @@ DEFAULTS = {
"local_binary": "", # empty -> whichever copy ggml.py finds
"cleanup_enabled": True,
- "cleanup_provider": "openrouter", # openrouter | local
+ "cleanup_provider": "openrouter", # a name in cleanup.PROVIDERS
"cleanup_model": "google/gemini-3.5-flash-lite",
+ "cleanup_claude_model": "haiku", # Claude Code: an alias, or a full model id
+ "cleanup_codex_model": "", # empty -> whatever Codex is set to
"cleanup_reasoning": "", # empty -> whatever the model does by default
# --- llama.cpp, on this machine -----------------------------------------
@@ -419,6 +425,10 @@ DEFAULTS = {
"min_voiced_seconds": 0.3,
"filter_hallucinations": True,
"shortcut": "Ctrl+Space",
+ # Ctrl+Alt+Space rather than Escape: the combination the recording started
+ # with, one modifier along. Escape belongs to whatever window has focus, and
+ # while you are dictating something else usually has it.
+ "cancel_shortcut": "Ctrl+Alt+Space",
"evdev_hotkey": False,
"overlay_corner": "bottom-left",
"keep_audio": False,
@@ -434,7 +444,6 @@ DEFAULTS = {
"meeting_language": "", # empty -> the dictation speech language
"meeting_max_seconds": 14400, # 4 hours
"meeting_cleanup": True,
- "meeting_provider": "openrouter", # openrouter | local
"meeting_model": "google/gemini-3.5-flash",
"meeting_reasoning": "",
"meeting_prompt": "", # empty -> language-specific default
@@ -474,6 +483,22 @@ LEGACY_PROMPTS = {
"154fc5aca1166f00eebda705f848f0391bfbf5fe", # 1.2 English
}
+# Every provider speech to text can run on, and the four settings that describe
+# one. A fifth is a row here rather than another branch in transcribe_target(),
+# another key row in the settings window and another line in save and load. The
+# order is the order the provider box offers them in. `service` is the name the
+# user sees; the environment variable that stands in for an empty key is the
+# name of its setting, shouted.
+Transcriber = collections.namedtuple("Transcriber", "service key url model")
+TRANSCRIBERS = {
+ "openai": Transcriber("OpenAI", "openai_api_key", "openai_base_url",
+ "transcribe_model"),
+ "groq": Transcriber("Groq", "groq_api_key", "groq_base_url",
+ "groq_transcribe_model"),
+ "openrouter": Transcriber("OpenRouter", "openrouter_api_key",
+ "openrouter_base_url", "openrouter_transcribe_model"),
+}
+
# Corners used to be stored with Turkish names.
_CORNER_MIGRATION = {
"sol-alt": "bottom-left", "sağ-alt": "bottom-right",
@@ -522,55 +547,40 @@ class Config:
def get(self, key, default=None):
return self.data.get(key, DEFAULTS.get(key, default))
+ def api_key(self, setting):
+ """A stored key, or the environment variable that shares its name."""
+ return self[setting].strip() or os.environ.get(setting.upper(), "").strip()
+
def openai_key(self):
- """Fall back to the environment when no key is stored."""
- return self["openai_api_key"].strip() or os.environ.get("OPENAI_API_KEY", "").strip()
+ return self.api_key("openai_api_key")
+
+ def groq_key(self):
+ return self.api_key("groq_api_key")
def openrouter_key(self):
- return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip()
+ return self.api_key("openrouter_api_key")
def transcribe_target(self):
"""Key, endpoint and model for whichever provider does speech to text.
- The local one leaves its base URL empty on purpose: the server picks a
- port when it starts, and starting it here would make reading a setting
- launch a process. api.py fills the address in when it is about to send
- the request, which is the moment the server is needed anyway.
+ The local one is not in the table and leaves its base URL empty on
+ purpose: the server picks a port when it starts, and reading a setting
+ must not be what launches a process. api.py fills the address in when it
+ is about to send the request, which is the moment the server is needed
+ anyway.
"""
- provider = self["transcribe_provider"]
- if provider == "local":
+ name = self["transcribe_provider"]
+ if name == "local":
return api.Target("local", t("Local whisper"), "", "",
self["local_model"])
- if provider == "openrouter":
- return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
- self["openrouter_base_url"],
- self["openrouter_transcribe_model"])
- return api.Target("openai", "OpenAI", self.openai_key(),
- self["openai_base_url"], self["transcribe_model"])
-
- def cleanup_target(self):
- """The same, for the model that tidies a transcript up."""
- if self["cleanup_provider"] == "local":
- return api.Target("local-llm", t("Local model"), "", "",
- self["local_llm_model"], self["local_llm_reasoning"])
- return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
- self["openrouter_base_url"], self["cleanup_model"],
- self["cleanup_reasoning"])
-
- def minutes_target(self):
- """The same again, for the minutes.
-
- Its own provider rather than the cleanup one. The two jobs are not the
- same size: a 4B model on this machine will strip the filler words out of
- a dictation perfectly well and will not write up an hour long meeting,
- so choosing it for the first must not quietly choose it for the second.
- """
- if self["meeting_provider"] == "local":
- return api.Target("local-llm", t("Local model"), "", "",
- self["local_llm_model"], self["local_llm_reasoning"])
- return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
- self["openrouter_base_url"], self["meeting_model"],
- self["meeting_reasoning"])
+ if name not in TRANSCRIBERS:
+ # A config written by a fork, or by a version that dropped one. The
+ # shipped default is not in the table, so this names the hosted one
+ # to land on rather than reading it from there.
+ name = "openai"
+ who = TRANSCRIBERS[name]
+ return api.Target(name, who.service, self.api_key(who.key),
+ self[who.url], self[who.model])
def transcribe_ready(self):
"""Whether speech to text could run right now, without opening Settings."""
@@ -606,7 +616,7 @@ class Config:
def uses_local_llm(self):
"""Whether anything is set to run the local cleanup model."""
- return "local" in (self["cleanup_provider"], self["meeting_provider"])
+ return self["cleanup_provider"] == "local"
def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
subtitles=False):
diff --git a/dikte.py b/dikte.py
index c667634..ef3e042 100755
--- a/dikte.py
+++ b/dikte.py
@@ -160,8 +160,10 @@ class Dikte:
self.ask_cancel_action.setEnabled(False)
self.menu.addAction(self.ask_cancel_action)
- self.cancel_action = QAction(t("Cancel recording"), self.menu)
- self.cancel_action.triggered.connect(self.cancel)
+ self.cancel_action = QAction(t("Discard the recording"), self.menu)
+ # The inner method, so that a menu click is never mistaken for the KDE
+ # shortcut echoing the built-in listener's press.
+ self.cancel_action.triggered.connect(self._cancel)
self.cancel_action.setEnabled(False)
self.menu.addAction(self.cancel_action)
self.menu.addSeparator()
@@ -314,6 +316,9 @@ class Dikte:
def toggle_meeting(self):
self._external("meeting", self._toggle_meeting)
+ def cancel(self):
+ self._external("cancel", self._cancel)
+
def _external(self, name, handler):
# The built-in listener sees the key press the instant it happens, so a
# toggle arriving right behind one is the KDE shortcut catching up on
@@ -331,7 +336,8 @@ class Dikte:
if timer is None:
timer = self.last_evdev[name] = QElapsedTimer()
timer.restart()
- handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask}
+ handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask,
+ "cancel": self._cancel}
handlers.get(name, self._toggle)()
def _retire_listener(self):
@@ -524,7 +530,7 @@ class Dikte:
self.ask_overlay.show_busy(t("Transcribing…"))
self.recorder.stop()
- def cancel(self):
+ def _cancel(self):
"""Throw away whichever recording is running."""
if not self.recording:
return
@@ -548,7 +554,7 @@ class Dikte:
def cancel_ask(self):
"""Call off the agent, whether it is still recording or already working."""
if self.ask_state == RECORDING:
- self.cancel()
+ self._cancel()
elif self.ask_state == BUSY:
self.ask_overlay.show_busy(t("Stopping…"))
self.ask_pipeline.cancel()
@@ -796,10 +802,7 @@ class Dikte:
def open_settings(self):
if self.settings_window is None:
- self.settings_window = SettingsWindow(
- self.conf, launch_command(), meeting_command(), self.meetings,
- ask_command(),
- )
+ self.settings_window = SettingsWindow(self.conf, self.meetings)
self.settings_window.applied.connect(self._apply_settings)
self.settings_window.finished.connect(self._settings_closed)
self.settings_window.show()
@@ -851,9 +854,8 @@ class Dikte:
self._build_tray()
self._refresh_tray()
if self.conf["evdev_hotkey"]:
- self.evdev.start({"toggle": self.conf["shortcut"],
- "ask": self.conf["assistant_shortcut"],
- "meeting": self.conf["meeting_shortcut"]})
+ self.evdev.start({name: self.conf[spec.setting]
+ for name, spec in hotkey.SHORTCUTS.items()})
else:
self.evdev.stop()
@@ -895,19 +897,6 @@ def _clock(seconds):
else f"{minutes}:{secs:02d}")
-def launch_command():
- """The command the KDE shortcut will run."""
- return ipc.command_for("toggle")
-
-
-def meeting_command():
- return ipc.command_for("meeting")
-
-
-def ask_command():
- return ipc.command_for("ask")
-
-
def main():
argv = sys.argv[1:]
# Anything typed at a terminal is the command line's business, including
diff --git a/filetranscribe.py b/filetranscribe.py
index d931264..f797d58 100644
--- a/filetranscribe.py
+++ b/filetranscribe.py
@@ -17,6 +17,7 @@ import wave
from PyQt6.QtCore import QObject, pyqtSignal
import api
+import cleanup
from i18n import t
CHUNK_SECONDS = 600 # 10 min ≈ 19 MB at 16 kHz mono s16
@@ -127,11 +128,10 @@ class FileTranscriber(QObject):
def _cleanup(self, text, timestamps):
conf = self.conf
prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True)
- target = conf.cleanup_target()
out = []
for block in split_text(text, timestamps):
self._check()
- out.append(api.cleanup(target, block, prompt))
+ out.append(cleanup.run(block, conf, prompt))
return ("\n" if timestamps else "\n\n").join(out)
diff --git a/hotkey.py b/hotkey.py
index b6b3ed1..f71de71 100644
--- a/hotkey.py
+++ b/hotkey.py
@@ -1,6 +1,7 @@
"""GNOME/KDE global-shortcut installation plus a built-in evdev listener."""
import ast
+import collections
import glob
import os
import pathlib
@@ -16,6 +17,7 @@ from PyQt6.QtCore import QObject, pyqtSignal
from i18n import t
DESKTOP_ID = "dikte-toggle.desktop"
+CANCEL_DESKTOP_ID = "dikte-cancel.desktop"
MEETING_DESKTOP_ID = "dikte-meeting.desktop"
ASK_DESKTOP_ID = "dikte-ask.desktop"
APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications"
@@ -24,6 +26,25 @@ SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc"
GNOME_MEDIA_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys"
GNOME_BINDING_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding"
+Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fallback")
+
+# Every global shortcut in one place, because there are four of them and the
+# command line, the settings window and the installer each used to carry their
+# own copy of the list. `fallback` is what to register when the setting is
+# empty: only the toggle has one, since it is the key the application is
+# unusable without.
+SHORTCUTS = {
+ "toggle": Shortcut("toggle", DESKTOP_ID, "Dikte: start/stop recording",
+ "shortcut", "Ctrl+Space"),
+ "cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording",
+ "cancel_shortcut", ""),
+ "ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code",
+ "assistant_shortcut", ""),
+ "meeting": Shortcut("meeting", MEETING_DESKTOP_ID,
+ "Dikte: start/end a meeting recording",
+ "meeting_shortcut", ""),
+}
+
# --- evdev key codes (linux/input-event-codes.h) --------------------------
EV_KEY = 0x01
@@ -371,14 +392,18 @@ def remove_kde_shortcut(desktop_id=DESKTOP_ID):
(APPLICATIONS_DIR / desktop_id).unlink(missing_ok=True)
except OSError:
pass
- try:
- subprocess.run(
- ["kwriteconfig6", "--notify", "--file", "kglobalshortcutsrc",
- "--group", "services", "--group", desktop_id, "--key", "_launch", "--delete"],
- capture_output=True, timeout=10,
- )
- except (subprocess.SubprocessError, OSError):
- pass
+ # kwriteconfig6 deletes keys rather than groups, so both of the ones KDE
+ # keeps in there go and the empty group is left behind harmlessly.
+ for key in ("_launch", "_k_friendly_name"):
+ try:
+ subprocess.run(
+ ["kwriteconfig6", "--notify", "--file", "kglobalshortcutsrc",
+ "--group", "services", "--group", desktop_id,
+ "--key", key, "--delete"],
+ capture_output=True, timeout=10,
+ )
+ except (subprocess.SubprocessError, OSError):
+ pass
def kde_shortcut_status(desktop_id=DESKTOP_ID):
diff --git a/i18n.py b/i18n.py
index dd70837..c414e34 100644
--- a/i18n.py
+++ b/i18n.py
@@ -56,7 +56,7 @@ TR = {
"Start recording": "Kaydı başlat",
"Stop and transcribe": "Kaydı bitir ve yaz",
"Working…": "İşleniyor…",
- "Cancel recording": "Kaydı iptal et",
+ "Discard the recording": "Kaydı iptal et",
"Settings…": "Ayarlar…",
"Restart": "Yeniden başlat",
"Quit": "Çık",
@@ -133,6 +133,7 @@ TR = {
"Cleanup rules": "Temizleme kuralları",
"Audio file": "Ses dosyası",
"Shortcut": "Kısayol",
+ "Shortcuts": "Kısayollar",
"History": "Geçmiş",
"Save": "Kaydet",
"Saved successfully.": "Başarıyla kaydedildi.",
@@ -177,6 +178,7 @@ TR = {
"Model": "Model",
"Provider": "Sağlayıcı",
"sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)",
+ "gsk_… (falls back to GROQ_API_KEY)": "gsk_… (boşsa GROQ_API_KEY kullanılır)",
"sk-or-… (falls back to OPENROUTER_API_KEY)": "sk-or-… (boşsa OPENROUTER_API_KEY kullanılır)",
"Test": "Test et",
"Trying…": "Deneniyor…",
@@ -184,6 +186,18 @@ TR = {
"Connection works. {count} audio models visible.":
"Bağlantı tamam. {count} ses modeli görünüyor.",
"Clean the transcript with a model": "Transkripti bir modelle temizle",
+ "OpenRouter is the quickest and the only one that needs nothing installed. "
+ "Claude Code and Codex clean up on the subscription you already have, "
+ "without a second key, and take a few seconds longer because each one opens "
+ "a session to do it.":
+ "En hızlısı OpenRouter'dır ve kurulu bir program istemeyen tek seçenektir. "
+ "Claude Code ile Codex, temizliği hâlihazırda ödediğin abonelik üzerinden "
+ "yapar, ikinci bir anahtar istemez; her biri bunun için bir oturum açtığından "
+ "birkaç saniye daha uzun sürer.",
+ "{binary} is not on your PATH, so cleanup would fail and the raw transcript "
+ "would be pasted. Install it, or pick another one above.":
+ "{binary} PATH'te değil; temizleme başarısız olur ve ham transkript "
+ "yapıştırılır. Kur ya da yukarıdan başka birini seç.",
"Thinking": "Düşünme",
"Model's own default": "Modelin kendi varsayılanı",
"Off": "Kapalı",
@@ -281,6 +295,13 @@ TR = {
"Global kısayol kurulu değil. Toplantı tepsi menüsünden de başlatılabilir.",
"No global shortcut installed. The tray menu asks it too.":
"Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.",
+ "No global shortcut installed. The tray menu discards it too.":
+ "Global kısayol kurulu değil. Kayıt tepsi menüsünden de iptal edilebilir.",
+ "Start and stop": "Başlat ve bitir",
+ "Throws the recording away without transcribing it. Works on a dictation "
+ "and on a command for the agent alike, whichever is running.":
+ "Kaydı yazıya dökmeden atar. Hangisi çalışıyorsa ona işler: dikteye de, "
+ "ajana verilen komuta da.",
"Shortcut saved: {shortcut}": "Kısayol kaydedildi: {shortcut}",
"Could not register the GNOME shortcut: {error}":
"GNOME kısayolu kaydedilemedi: {error}",
@@ -371,6 +392,14 @@ TR = {
"It was not allowed to use: {tools}": "Şunları kullanmasına izin yoktu: {tools}",
"The model returned an empty reply.": "Model boş cevap döndürdü.",
+ # --- cleanup, when a CLI does it ----------------------------------------
+ "{binary} not found. Install it, or have OpenRouter clean up instead, "
+ "under Settings → API and models.":
+ "{binary} bulunamadı. Kur ya da Ayarlar → API ve modeller sekmesinden "
+ "temizliği OpenRouter'a bırak.",
+ "{service} did not finish within {seconds} seconds.":
+ "{service} {seconds} saniye içinde bitmedi.",
+
# --- settings: the agent ------------------------------------------------
"Agent": "Ajan",
"This shortcut records the same way dictation does, but the transcript is "
diff --git a/install.sh b/install.sh
index 10a89c4..c55e5b8 100755
--- a/install.sh
+++ b/install.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# Dikte installer: dependency check, launchers, KDE shortcut.
+# Dikte installer: dependency check, launchers, global shortcuts.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -8,6 +8,9 @@ BIN_DIR="$HOME/.local/bin"
APP_DIR="$HOME/.local/share/applications"
AUTOSTART_DIR="$HOME/.config/autostart"
SHORTCUT="${1:-Ctrl+Space}"
+# Without the colon, so that a second argument given as "" stays empty. That is
+# how update.sh says "this one was turned off", as against not saying anything.
+CANCEL_SHORTCUT="${2-Ctrl+Alt+Space}"
say() { printf ' %s\n' "$1"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
@@ -88,30 +91,43 @@ StartupNotify=false
EOF
ok "Will start automatically on login"
-# 4. KDE global shortcut ---------------------------------------------------
-cat > "$APP_DIR/dikte-toggle.desktop" < Shortcut to install the global shortcut."
-elif command -v kwriteconfig6 >/dev/null; then
- kwriteconfig6 --notify --file kglobalshortcutsrc \
- --group services --group dikte-toggle.desktop \
- --key _launch "$SHORTCUT"
- ok "KDE shortcut registered: $SHORTCUT"
- warn "KWin only reads this at startup, so the shortcut goes live after your"
- say "next login. Until then open Settings → Shortcut and turn on the"
- say "built-in listener to use it right away."
+register() { # which combination label
+ if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then
+ ok "$3: $2"
+ else
+ # One line: the rest of what it has to say about KWin is printed below.
+ warn "${out%%$'\n'*}"
+ fi
+}
+
+if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then
+ register toggle "$SHORTCUT" "Start and stop"
+ if [[ -n "$CANCEL_SHORTCUT" ]]; then
+ register cancel "$CANCEL_SHORTCUT" "Discard the recording"
+ fi
+ if [[ "${XDG_CURRENT_DESKTOP:-}" != *[Gg][Nn][Oo][Mm][Ee]* ]]; then
+ warn "KWin only reads these at startup, so they go live after your next"
+ say "login. Until then open Settings → Shortcuts and turn on the"
+ say "built-in listener to use them right away."
+ fi
else
- warn "No supported shortcut manager found. Add the shortcut in desktop settings."
+ warn "PyQt6 is missing, so no shortcut was registered. Install it, then run:"
+ say "dikte shortcut install toggle --combo '$SHORTCUT'"
fi
echo
diff --git a/meeting.py b/meeting.py
index d6bfbbe..c178668 100644
--- a/meeting.py
+++ b/meeting.py
@@ -25,6 +25,7 @@ import wave
from PyQt6.QtCore import QObject, pyqtSignal
import api
+import cleanup
import config as cfg
import filetranscribe
import vad
@@ -118,12 +119,18 @@ class MeetingPipeline(QObject):
self._check()
self._say(t("Writing the minutes…"))
- writer = self.conf.minutes_target()
- minutes = api.cleanup(writer, transcript, self.conf.meeting_prompt(),
- timeout=600)
+ minutes = api.cleanup(
+ transcript,
+ self.conf.openrouter_key(),
+ self.conf["meeting_model"],
+ self.conf.meeting_prompt(),
+ reasoning=self.conf["meeting_reasoning"],
+ base_url=self.conf["openrouter_base_url"],
+ timeout=600,
+ )
title = self._write(doc_path, minutes, transcript, entry)
cfg.update_meeting(base, status="done", error="", title=title,
- model=writer.model)
+ model=self.conf["meeting_model"])
self._discard_audio(wav_path)
self.finished.emit(base, title)
@@ -195,7 +202,6 @@ class MeetingPipeline(QObject):
def _cleanup(self, transcript):
conf = self.conf
prompt = conf.cleanup_prompt(with_timestamps=True, with_speakers=True)
- target = conf.cleanup_target()
out = []
blocks = filetranscribe.split_text(transcript, True)
for index, block in enumerate(blocks, start=1):
@@ -203,7 +209,7 @@ class MeetingPipeline(QObject):
if len(blocks) > 1:
self._say(t("Cleaning up {index}/{count}…",
index=index, count=len(blocks)))
- out.append(api.cleanup(target, block, prompt))
+ out.append(cleanup.run(block, conf, prompt, timeout=600))
return "\n".join(out)
def _write(self, doc_path, minutes, transcript, entry):
diff --git a/settings_ui.py b/settings_ui.py
index 1ed8d0a..239a049 100644
--- a/settings_ui.py
+++ b/settings_ui.py
@@ -16,10 +16,12 @@ from PyQt6.QtWidgets import (
import api
import assistant
import audio
+import cleanup
import config as cfg
import filetranscribe
import ggml
import hotkey
+import ipc
import meeting
from filetranscribe import FileTranscriber
from i18n import t
@@ -30,14 +32,15 @@ LANGUAGES = [
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
]
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
-TRANSCRIBE_PROVIDERS = [("This machine (whisper.cpp)", "local"),
- ("OpenAI", "openai"), ("OpenRouter", "openrouter")]
-CLEANUP_PROVIDERS = [("OpenRouter", "openrouter"),
- ("This machine (llama.cpp)", "local")]
+# The provider box offers what config knows how to reach, this machine first.
+TRANSCRIBE_PROVIDERS = ([("This machine (whisper.cpp)", "local")]
+ + [(who.service, name)
+ for name, who in cfg.TRANSCRIBERS.items()])
# Starting points for the model box; "Fetch model list" replaces them with
# whatever the provider offers today.
TRANSCRIBE_MODELS = {
"openai": ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"],
+ "groq": ["whisper-large-v3-turbo", "whisper-large-v3"],
"openrouter": [
"openai/gpt-4o-transcribe", "openai/gpt-4o-mini-transcribe",
"openai/whisper-1", "openai/whisper-large-v3",
@@ -50,6 +53,16 @@ CLEANUP_MODELS = [
"google/gemini-2.5-flash-lite", "anthropic/claude-haiku-4.5",
"openai/gpt-5-mini", "meta-llama/llama-3.3-70b-instruct",
]
+# In the order they answer in. A request to OpenRouter is over in a second, a
+# model here takes a little longer and costs nothing, and the two CLIs the agent
+# can run on open a whole session to do the smaller job.
+CLEANUP_PROVIDERS = [
+ ("OpenRouter", "openrouter"), ("This machine (llama.cpp)", "local"),
+ ("Claude Code", "claude"), ("Codex", "codex"),
+]
+# Cleaning up a sentence is the lightest thing either of them will ever be
+# asked, so the small model comes first.
+CLEANUP_CLAUDE_MODELS = ["haiku", "sonnet", "opus", "fable"]
# Minutes are a harder job than cleanup: an hour of talk has to be read whole
# and turned into decisions, so the starting points are the larger models.
MEETING_MODELS = [
@@ -94,9 +107,9 @@ REASONING_LEVELS = [
("Very high", "xhigh"), ("Maximum", "max"),
]
PASTE_SHORTCUTS = ["ctrl+v", "ctrl+shift+v", "shift+insert"]
-# Offered for all three global shortcuts, which keeps them one kind of field
-# rather than three. The boxes stay editable: this is a shortlist of
-# combinations that are usually free, not the set of ones that work.
+# Offered for every global shortcut, which keeps them one kind of field rather
+# than four. The boxes stay editable: this is a shortlist of combinations that
+# are usually free, not the set of ones that work.
SHORTCUTS = [
"Ctrl+Space", "Ctrl+Alt+Space", "Ctrl+Shift+Space", "Meta+Space",
"Ctrl+Alt+A", "Ctrl+Alt+D", "Ctrl+Alt+M", "Ctrl+Alt+Q",
@@ -444,20 +457,23 @@ class SettingsWindow(QDialog):
_models_loaded = pyqtSignal(list, str)
_transcribe_models_loaded = pyqtSignal(list, str)
- _test_done = pyqtSignal(bool, str)
- _or_test_done = pyqtSignal(bool, str)
+ # Which key was tested, whether it worked, and what to write under it.
+ _test_done = pyqtSignal(str, bool, str)
- def __init__(self, conf, launch_command, meeting_command=None,
- meetings=None, ask_command=None, parent=None):
+ def __init__(self, conf, meetings=None, parent=None):
super().__init__(parent)
self.conf = conf
- self.launch_command = launch_command
- self.meeting_command = meeting_command or launch_command
- self.ask_command = ask_command or launch_command
self.meetings = meetings
+ # Filled in by _shortcut_row as the tabs are built: which combination
+ # box, status label and "nothing installed" line belong to each of the
+ # global shortcuts. One dictionary is what lets install, remove and the
+ # status line be written once instead of once per key.
+ self._shortcut_rows = {}
# Each provider keeps its own transcription model, so switching the
# provider back and forth never overwrites the other one's.
- self._models = {"openai": "", "openrouter": ""}
+ self._models = dict.fromkeys(cfg.TRANSCRIBERS, "")
+ self._key_fields = {}
+ self._testers = {}
self._shown_provider = ""
self.transcriber = FileTranscriber(conf, self)
self.setWindowTitle(t("Dikte Settings"))
@@ -471,7 +487,7 @@ class SettingsWindow(QDialog):
tabs.addTab(self._meeting_tab(), t("Meeting"))
tabs.addTab(self._minutes_tab(), t("Minutes"))
tabs.addTab(self._file_tab(), t("Audio file"))
- tabs.addTab(self._shortcut_tab(), t("Shortcut"))
+ tabs.addTab(self._shortcut_tab(), t("Shortcuts"))
tabs.addTab(self._history_tab(), t("History"))
# Save keeps the window open, so the window is closed with the titlebar
@@ -488,7 +504,6 @@ class SettingsWindow(QDialog):
self._models_loaded.connect(self._on_models_loaded)
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
self._test_done.connect(self._on_test_done)
- self._or_test_done.connect(self._on_or_test_done)
self.transcriber.progress.connect(self._on_file_progress)
self.transcriber.finished.connect(self._on_file_finished)
self.transcriber.failed.connect(self._on_file_failed)
@@ -584,25 +599,15 @@ class SettingsWindow(QDialog):
# them and a key no longer belongs to a single job.
keys = QGroupBox(t("Keys"))
keys_form = QFormLayout(keys)
- self.openai_key = QLineEdit()
- self.openai_key.setEchoMode(QLineEdit.EchoMode.Password)
- self.openai_key.setPlaceholderText(t("sk-… (falls back to OPENAI_API_KEY)"))
- self.test_button = QPushButton(t("Test"))
- self.test_button.clicked.connect(self._test_openai)
- self.test_label = QLabel("")
- self.test_label.setWordWrap(True)
- keys_form.addRow("OpenAI", self._row(self.openai_key, self.test_button))
- keys_form.addRow("", self.test_label)
-
- self.openrouter_key = QLineEdit()
- self.openrouter_key.setEchoMode(QLineEdit.EchoMode.Password)
- self.openrouter_key.setPlaceholderText(t("sk-or-… (falls back to OPENROUTER_API_KEY)"))
- self.or_test_button = QPushButton(t("Test"))
- self.or_test_button.clicked.connect(self._test_openrouter)
- self.or_test_label = QLabel("")
- self.or_test_label.setWordWrap(True)
- keys_form.addRow("OpenRouter", self._row(self.openrouter_key, self.or_test_button))
- keys_form.addRow("", self.or_test_label)
+ self.openai_key = self._key_row(
+ keys_form, "openai", t("sk-… (falls back to OPENAI_API_KEY)"),
+ self._test_openai)
+ self.groq_key = self._key_row(
+ keys_form, "groq", t("gsk_… (falls back to GROQ_API_KEY)"),
+ self._test_groq)
+ self.openrouter_key = self._key_row(
+ keys_form, "openrouter", t("sk-or-… (falls back to OPENROUTER_API_KEY)"),
+ self._test_openrouter)
outer.addWidget(keys)
stt = QGroupBox(t("Speech to text"))
@@ -612,25 +617,22 @@ class SettingsWindow(QDialog):
self.transcribe_provider.addItem(t(label), value)
stt_form.addRow(t("Provider"), self.transcribe_provider)
- # The hosted providers take any model id that is typed at them; the
- # local one offers what has been published, so the two are separate
- # blocks and only one of them is ever visible.
- self.hosted_stt = QWidget()
- hosted_form = QFormLayout(self.hosted_stt)
- hosted_form.setContentsMargins(0, 0, 0, 0)
+ # A hosted provider takes any model id that is typed at it; the local
+ # one offers what has been published. One row each, and only the rows of
+ # whoever is chosen are on screen.
+ self.stt_form = stt_form
self.transcribe_model = QComboBox()
self.transcribe_model.setEditable(True)
self.refresh_transcribe_models = QPushButton(t("Fetch model list"))
self.refresh_transcribe_models.clicked.connect(self._load_transcribe_models)
- hosted_form.addRow(t("Model"),
- self._row(self.transcribe_model,
- self.refresh_transcribe_models))
+ self.transcribe_model_row = self._row(self.transcribe_model,
+ self.refresh_transcribe_models)
+ stt_form.addRow(t("Model"), self.transcribe_model_row)
# A spanning row: in the narrow field column a wrapped label gets a
# height that fits one line, and the rest of the text is cut off.
self.transcribe_status = QLabel("")
self.transcribe_status.setWordWrap(True)
- hosted_form.addRow(self.transcribe_status)
- stt_form.addRow(self.hosted_stt)
+ stt_form.addRow(self.transcribe_status)
self.local_whisper = LocalModelBox(
ggml.WHISPER, t("On this machine"),
@@ -662,26 +664,43 @@ class SettingsWindow(QDialog):
outer.addWidget(stt)
orr = QGroupBox(t("Transcript cleanup"))
- orr_form = QFormLayout(orr)
+ orr_form = self.cleanup_form = QFormLayout(orr)
self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model"))
orr_form.addRow("", self.cleanup_enabled)
self.cleanup_provider = QComboBox()
for label, value in CLEANUP_PROVIDERS:
self.cleanup_provider.addItem(t(label), value)
+ self.cleanup_provider.setToolTip(t(
+ "OpenRouter is the quickest and the only one that needs nothing "
+ "installed. llama.cpp runs here, on a model downloaded below. Claude "
+ "Code and Codex clean up on the subscription you already have, "
+ "without a second key, and take a few seconds longer because each "
+ "one opens a session to do it."
+ ))
self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed)
- orr_form.addRow(t("Provider"), self.cleanup_provider)
+ orr_form.addRow(t("Runs on"), self.cleanup_provider)
- self.hosted_cleanup = QWidget()
- cleanup_form = QFormLayout(self.hosted_cleanup)
- cleanup_form.setContentsMargins(0, 0, 0, 0)
self.cleanup_model = QComboBox()
self.cleanup_model.setEditable(True)
self.cleanup_model.addItems(CLEANUP_MODELS)
self.refresh_models = QPushButton(t("Fetch model list"))
self.refresh_models.clicked.connect(self._load_models)
- cleanup_form.addRow(t("Model"), self._row(self.cleanup_model,
- self.refresh_models))
+ self.cleanup_model_row = self._row(self.cleanup_model, self.refresh_models)
+ orr_form.addRow(t("Model"), self.cleanup_model_row)
+
+ # One row per provider rather than one box that means a different thing
+ # in each: an OpenRouter id and a Claude alias do not belong in the same
+ # field, and only the row of whoever is chosen is on screen.
+ self.cleanup_claude_model = QComboBox()
+ self.cleanup_claude_model.setEditable(True)
+ self.cleanup_claude_model.addItems(CLEANUP_CLAUDE_MODELS)
+ orr_form.addRow(t("Model"), self.cleanup_claude_model)
+
+ self.cleanup_codex_model = QComboBox()
+ self.cleanup_codex_model.setEditable(True)
+ self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS)
+ orr_form.addRow(t("Model"), self.cleanup_codex_model)
self.cleanup_reasoning = QComboBox()
for label, value in REASONING_LEVELS:
@@ -691,12 +710,11 @@ class SettingsWindow(QDialog):
"a light job, so more thinking mostly costs time and tokens. Models "
"that cannot think ignore this.")
)
- cleanup_form.addRow(t("Thinking"), self.cleanup_reasoning)
+ orr_form.addRow(t("Thinking"), self.cleanup_reasoning)
self.models_label = QLabel(t("Runs on OpenRouter."))
self.models_label.setWordWrap(True)
- cleanup_form.addRow(self.models_label)
- orr_form.addRow(self.hosted_cleanup)
+ orr_form.addRow(self.models_label)
self.local_llm = LocalModelBox(
ggml.LLAMA, t("On this machine"),
@@ -717,6 +735,7 @@ class SettingsWindow(QDialog):
"waiting. Off is what cleanup wants."))
self.local_llm_options = QWidget()
llm_form = QFormLayout(self.local_llm_options)
+
llm_form.setContentsMargins(0, 0, 0, 0)
llm_form.addRow("", self.local_llm_gpu)
llm_form.addRow("", self.local_llm_preload)
@@ -779,16 +798,10 @@ class SettingsWindow(QDialog):
how = QGroupBox(t("How it runs"))
how_form = QFormLayout(how)
- self.assistant_shortcut = self._shortcut_box(t("none"))
- install = QPushButton(t("Install as a KDE shortcut"))
- install.clicked.connect(self._install_ask_shortcut)
- remove = QPushButton(t("Remove"))
- remove.clicked.connect(self._remove_ask_shortcut)
- how_form.addRow(t("Shortcut"),
- self._row(self.assistant_shortcut, install, remove))
- self.assistant_shortcut_status = QLabel("")
- self.assistant_shortcut_status.setWordWrap(True)
- how_form.addRow(self.assistant_shortcut_status)
+ self._shortcut_row(
+ how_form, "ask", t("Shortcut"),
+ t("No global shortcut installed. The tray menu asks it too."),
+ )
self.assistant_provider = QComboBox()
for label, value in ASSISTANT_PROVIDERS:
@@ -1041,16 +1054,10 @@ class SettingsWindow(QDialog):
))
recording_form.addRow("", self.meeting_keep_audio)
- self.meeting_shortcut = self._shortcut_box(t("none"))
- install = QPushButton(t("Install as a KDE shortcut"))
- install.clicked.connect(self._install_meeting_shortcut)
- remove = QPushButton(t("Remove"))
- remove.clicked.connect(self._remove_meeting_shortcut)
- recording_form.addRow(t("Shortcut"),
- self._row(self.meeting_shortcut, install, remove))
- self.meeting_shortcut_status = QLabel("")
- self.meeting_shortcut_status.setWordWrap(True)
- recording_form.addRow(self.meeting_shortcut_status)
+ self._shortcut_row(
+ recording_form, "meeting", t("Shortcut"),
+ t("No global shortcut installed. The tray menu starts a meeting too."),
+ )
layout.addWidget(recording)
prompt_label = QLabel(t("System instruction given to the minutes model."))
@@ -1191,25 +1198,27 @@ class SettingsWindow(QDialog):
def _shortcut_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
+ # Both keys in one form, the way the Meeting and Agent tabs already lay
+ # theirs out. Two forms would give each row a label column of its own,
+ # and two combination boxes starting at different places read as two
+ # unrelated settings rather than the pair they are.
form = QFormLayout()
- self.shortcut = self._shortcut_box("Ctrl+Space")
- form.addRow(t("Shortcut"), self.shortcut)
+ self._shortcut_row(
+ form, "toggle", t("Start and stop"),
+ t("No global shortcut installed."), placeholder="Ctrl+Space",
+ )
+ # Stopping is what sends the recording off to be transcribed, and that
+ # is the step there is no taking back. By the time the tray menu is
+ # open the sentence you did not mean to dictate is already on its way.
+ self._shortcut_row(
+ form, "cancel", t("Discard the recording"),
+ t("No global shortcut installed. The tray menu discards it too."),
+ tooltip=t("Throws the recording away without transcribing it. Works "
+ "on a dictation and on a command for the agent alike, "
+ "whichever is running."),
+ )
layout.addLayout(form)
- install = QPushButton(t("Install as a KDE shortcut"))
- install.clicked.connect(self._install_shortcut)
- remove = QPushButton(t("Remove"))
- remove.clicked.connect(self._remove_shortcut)
- row = QHBoxLayout()
- row.addWidget(install)
- row.addWidget(remove)
- row.addStretch(1)
- layout.addLayout(row)
-
- self.shortcut_status = QLabel("")
- self.shortcut_status.setWordWrap(True)
- layout.addWidget(self.shortcut_status)
-
self.evdev_enabled = QCheckBox(t(
"Use the built-in listener (/dev/input), for when the KDE shortcut is "
"not active yet"
@@ -1303,6 +1312,45 @@ class SettingsWindow(QDialog):
box.lineEdit().setPlaceholderText(placeholder)
return box
+ def _key_row(self, form, provider, placeholder, tester):
+ """A key field, its Test button and the line the answer lands on.
+
+ The field and the pair the answer needs are filed under the provider's
+ name, so saving, loading and the test handler find them by name rather
+ than through three attributes each.
+ """
+ field = QLineEdit()
+ field.setEchoMode(QLineEdit.EchoMode.Password)
+ field.setPlaceholderText(placeholder)
+ button = QPushButton(t("Test"))
+ button.clicked.connect(tester)
+ answer = QLabel("")
+ answer.setWordWrap(True)
+ form.addRow(cfg.TRANSCRIBERS[provider].service, self._row(field, button))
+ form.addRow("", answer)
+ self._key_fields[provider] = field
+ self._testers[provider] = (button, answer)
+ return field
+
+ def _shortcut_row(self, form, which, label, missing, placeholder="",
+ tooltip=""):
+ """One global shortcut: the combination, Install, Remove, and a line
+ saying what the desktop has registered. `missing` is what that line
+ says when nothing is."""
+ box = self._shortcut_box(placeholder or t("none"))
+ if tooltip:
+ box.setToolTip(tooltip)
+ install = QPushButton(t("Install as a KDE shortcut"))
+ install.clicked.connect(lambda: self._install_shortcut(which))
+ remove = QPushButton(t("Remove"))
+ remove.clicked.connect(lambda: self._remove_shortcut(which))
+ form.addRow(label, self._row(box, install, remove))
+ status = QLabel("")
+ status.setWordWrap(True)
+ form.addRow(status)
+ self._shortcut_rows[which] = (box, status, missing)
+ return box
+
@staticmethod
def _row(*widgets):
"""Widgets side by side in one form row; the first one takes the space."""
@@ -1331,10 +1379,9 @@ class SettingsWindow(QDialog):
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
self.keep_audio.setChecked(conf["keep_audio"])
- self.openai_key.setText(conf["openai_api_key"])
- self.openrouter_key.setText(conf["openrouter_api_key"])
- self._models = {"openai": conf["transcribe_model"],
- "openrouter": conf["openrouter_transcribe_model"]}
+ for name, who in cfg.TRANSCRIBERS.items():
+ self._key_fields[name].setText(conf[who.key])
+ self._models[name] = conf[who.model]
self._shown_provider = ""
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
self._provider_changed() # selecting index 0 fires no signal
@@ -1344,9 +1391,13 @@ class SettingsWindow(QDialog):
self.local_whisper.load(conf["local_model"])
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
- self._select_data(self.cleanup_provider, conf["cleanup_provider"])
- self._cleanup_provider_changed()
self.cleanup_model.setCurrentText(conf["cleanup_model"])
+ self.cleanup_claude_model.setCurrentText(conf["cleanup_claude_model"])
+ self.cleanup_codex_model.setCurrentText(
+ conf["cleanup_codex_model"] or t("Codex's own default")
+ )
+ self._select_data(self.cleanup_provider, conf["cleanup_provider"])
+ self._cleanup_provider_changed() # selecting index 0 fires no signal
self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"])
self.local_llm_gpu.setChecked(conf["local_llm_gpu"])
self.local_llm_preload.setChecked(conf["local_llm_preload"])
@@ -1358,7 +1409,6 @@ class SettingsWindow(QDialog):
)
self.transcribe_prompt.setPlainText(conf["transcribe_prompt"])
- self.assistant_shortcut.setCurrentText(conf["assistant_shortcut"])
self._select_data(self.assistant_provider, conf["assistant_provider"])
self.assistant_model.setCurrentText(conf["assistant_model"])
self._select_data(self.assistant_permission, conf["assistant_permission_mode"])
@@ -1387,7 +1437,6 @@ class SettingsWindow(QDialog):
self.meeting_cleanup.setChecked(conf["meeting_cleanup"])
self.meeting_max_minutes.setValue(max(5, int(conf["meeting_max_seconds"]) // 60))
self.meeting_keep_audio.setChecked(conf["meeting_keep_audio"])
- self.meeting_shortcut.setCurrentText(conf["meeting_shortcut"])
self.meeting_prompt.setPlainText(
conf["meeting_prompt"] or cfg.default_meeting_prompt()
)
@@ -1396,14 +1445,14 @@ class SettingsWindow(QDialog):
self.file_cleanup.setChecked(conf["file_cleanup"])
self.file_path = ""
- self.shortcut.setCurrentText(conf["shortcut"])
+ for which, (box, _status, _missing) in self._shortcut_rows.items():
+ box.setCurrentText(conf[hotkey.SHORTCUTS[which].setting])
self.evdev_enabled.setChecked(conf["evdev_hotkey"])
self.history_limit.setValue(max(0, int(conf["history_limit"])))
- self._refresh_shortcut_status()
- self._refresh_meeting_shortcut_status()
- self._refresh_ask_shortcut_status()
+ for which in self._shortcut_rows:
+ self._refresh_shortcut_status(which)
self._refresh_assistant_status()
self._load_history()
self._load_minutes()
@@ -1423,16 +1472,13 @@ class SettingsWindow(QDialog):
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
conf["keep_audio"] = self.keep_audio.isChecked()
- conf["openai_api_key"] = self.openai_key.text().strip()
- conf["openrouter_api_key"] = self.openrouter_key.text().strip()
-
provider = self.transcribe_provider.currentData() or "local"
- if provider in TRANSCRIBE_MODELS:
+ if provider in self._models:
self._models[provider] = self.transcribe_model.currentText().strip()
conf["transcribe_provider"] = provider
- for key, name in (("openai", "transcribe_model"),
- ("openrouter", "openrouter_transcribe_model")):
- conf[name] = self._models[key].strip() or cfg.DEFAULTS[name]
+ for name, who in cfg.TRANSCRIBERS.items():
+ conf[who.key] = self._key_fields[name].text().strip()
+ conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
conf["local_model"] = self.local_whisper.selected()
conf["local_gpu"] = self.local_gpu.isChecked()
conf["local_preload"] = self.local_preload.isChecked()
@@ -1441,6 +1487,12 @@ class SettingsWindow(QDialog):
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
conf["cleanup_provider"] = self.cleanup_provider.currentData() or "openrouter"
conf["cleanup_model"] = self.cleanup_model.currentText().strip()
+ conf["cleanup_claude_model"] = (self.cleanup_claude_model.currentText().strip()
+ or cfg.DEFAULTS["cleanup_claude_model"])
+ codex_cleanup_model = self.cleanup_codex_model.currentText().strip()
+ conf["cleanup_codex_model"] = (
+ "" if codex_cleanup_model == t("Codex's own default") else codex_cleanup_model
+ )
conf["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or ""
conf["local_llm_model"] = self.local_llm.selected()
conf["local_llm_repo"] = self.local_llm.repository()
@@ -1457,7 +1509,6 @@ class SettingsWindow(QDialog):
else file_prompt)
conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip()
- conf["assistant_shortcut"] = self.assistant_shortcut.currentText().strip()
conf["assistant_provider"] = self.assistant_provider.currentData() or "claude"
conf["assistant_model"] = (self.assistant_model.currentText().strip()
or cfg.DEFAULTS["assistant_model"])
@@ -1497,7 +1548,6 @@ class SettingsWindow(QDialog):
conf["meeting_cleanup"] = self.meeting_cleanup.isChecked()
conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60
conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked()
- conf["meeting_shortcut"] = self.meeting_shortcut.currentText().strip()
meeting_prompt = self.meeting_prompt.toPlainText().strip()
conf["meeting_prompt"] = ("" if meeting_prompt == cfg.default_meeting_prompt()
else meeting_prompt)
@@ -1505,7 +1555,12 @@ class SettingsWindow(QDialog):
conf["file_timestamps"] = self.file_timestamps.isChecked()
conf["file_cleanup"] = self.file_cleanup.isChecked()
- conf["shortcut"] = self.shortcut.currentText().strip() or "Ctrl+Space"
+ # Left empty, only the toggle falls back to a default: the application
+ # is unusable without it. The other three stay empty, which is what
+ # turns them off.
+ for which, (box, _status, _missing) in self._shortcut_rows.items():
+ spec = hotkey.SHORTCUTS[which]
+ conf[spec.setting] = box.currentText().strip() or spec.fallback
conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
conf["history_limit"] = self.history_limit.value()
conf.save()
@@ -1532,9 +1587,10 @@ class SettingsWindow(QDialog):
provider = self.transcribe_provider.currentData() or "local"
self._shown_provider = provider
local = provider == "local"
- self.hosted_stt.setVisible(not local)
- self.local_whisper.setVisible(local)
- self.local_options.setVisible(local)
+ self.stt_form.setRowVisible(self.transcribe_model_row, not local)
+ self.stt_form.setRowVisible(self.transcribe_status, not local)
+ self.stt_form.setRowVisible(self.local_whisper, local)
+ self.stt_form.setRowVisible(self.local_options, local)
if local:
return
self.transcribe_model.clear()
@@ -1542,26 +1598,19 @@ class SettingsWindow(QDialog):
self.transcribe_model.setCurrentText(self._models[provider])
self.transcribe_status.setText("")
- def _cleanup_provider_changed(self):
- local = (self.cleanup_provider.currentData() or "openrouter") == "local"
- self.hosted_cleanup.setVisible(not local)
- self.local_llm.setVisible(local)
- self.local_llm_options.setVisible(local)
-
def _load_transcribe_models(self):
"""The model list of whichever provider is selected."""
provider = self.transcribe_provider.currentData() or "openai"
self.refresh_transcribe_models.setEnabled(False)
self.transcribe_status.setText(t("Fetching model list…"))
- openai_key = self.openai_key.text().strip() or self.conf.openai_key()
- openrouter_key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
- base = self.conf["openai_base_url"]
+ key, base = self._typed_key(provider)
+ service = cfg.TRANSCRIBERS[provider].service
def work():
try:
- models = (api.openrouter_models(openrouter_key, transcription=True)
+ models = (api.openrouter_models(key, transcription=True)
if provider == "openrouter"
- else api.openai_models(openai_key, base))
+ else api.openai_models(key, base, service))
self._transcribe_models_loaded.emit(models, "")
except api.ApiError as exc:
self._transcribe_models_loaded.emit([], str(exc))
@@ -1605,42 +1654,51 @@ class SettingsWindow(QDialog):
self.models_label.setText(t("{count} models loaded.", count=len(models)))
def _test_openai(self):
- self.test_button.setEnabled(False)
- self.test_label.setText(t("Trying…"))
- key = self.openai_key.text().strip() or self.conf.openai_key()
- base = self.conf["openai_base_url"]
+ key, base = self._typed_key("openai")
+ self._test_key("openai", lambda: t(
+ "Connection works. {count} audio models visible.",
+ count=len(api.openai_models(key, base)),
+ ))
- def work():
- try:
- models = api.openai_models(key, base)
- self._test_done.emit(
- True, t("Connection works. {count} audio models visible.", count=len(models))
- )
- except api.ApiError as exc:
- self._test_done.emit(False, str(exc))
-
- threading.Thread(target=work, daemon=True).start()
+ def _test_groq(self):
+ key, base = self._typed_key("groq")
+ self._test_key("groq", lambda: t(
+ "Connection works. {count} audio models visible.",
+ count=len(api.openai_models(key, base, cfg.TRANSCRIBERS["groq"].service)),
+ ))
def _test_openrouter(self):
- self.or_test_button.setEnabled(False)
- self.or_test_label.setText(t("Trying…"))
- key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
+ key, _ = self._typed_key("openrouter")
+ self._test_key("openrouter", lambda: api.openrouter_key_status(key))
+
+ def _typed_key(self, provider):
+ """(key, base URL) for a provider, preferring what is in the field now."""
+ who = cfg.TRANSCRIBERS[provider]
+ typed = self._key_fields[provider].text().strip()
+ return typed or self.conf.api_key(who.key), self.conf[who.url]
+
+ def _test_key(self, provider, ask):
+ """Run `ask` off the interface thread and write its answer under the key.
+
+ `ask` returns the line to show, or raises ApiError with the line to show
+ instead; either way it is read from a field before the thread starts.
+ """
+ button, answer = self._testers[provider]
+ button.setEnabled(False)
+ answer.setText(t("Trying…"))
def work():
try:
- self._or_test_done.emit(True, api.openrouter_key_status(key))
+ self._test_done.emit(provider, True, ask())
except api.ApiError as exc:
- self._or_test_done.emit(False, str(exc))
+ self._test_done.emit(provider, False, str(exc))
threading.Thread(target=work, daemon=True).start()
- def _on_or_test_done(self, ok, message):
- self.or_test_button.setEnabled(True)
- self.or_test_label.setText(("✓ " if ok else "✗ ") + message)
-
- def _on_test_done(self, ok, message):
- self.test_button.setEnabled(True)
- self.test_label.setText(("✓ " if ok else "✗ ") + message)
+ def _on_test_done(self, provider, ok, message):
+ button, answer = self._testers[provider]
+ button.setEnabled(True)
+ answer.setText(("✓ " if ok else "✗ ") + message)
# ---- audio file ------------------------------------------------------
@@ -1722,45 +1780,17 @@ class SettingsWindow(QDialog):
except OSError as exc:
self.file_status.setText(t("Failed: {error}", error=exc))
- # ---- shortcut --------------------------------------------------------
+ # ---- shortcuts -------------------------------------------------------
- def _install_shortcut(self):
- combo = self.shortcut.currentText().strip() or "Ctrl+Space"
- clashes = hotkey.conflicting_shortcuts(combo)
- if clashes:
- answer = QMessageBox.question(
- self, t("Shortcut conflict"),
- t("{shortcut} is also used by:\n\n{list}\n\nInstall anyway?",
- shortcut=combo, list="\n".join(clashes[:6])),
- )
- if answer != QMessageBox.StandardButton.Yes:
- return
- ok, message = hotkey.install_shortcut(combo, self.launch_command)
- QMessageBox.information(self, t("Shortcut"), message)
- if ok:
- self.conf["shortcut"] = combo
- self.conf.save()
- self._refresh_shortcut_status()
-
- def _remove_shortcut(self):
- hotkey.remove_shortcut()
- self._refresh_shortcut_status()
-
- def _refresh_shortcut_status(self):
- current = hotkey.shortcut_status()
- self.shortcut_status.setText(
- t("Registered in {desktop}: {shortcut}",
- desktop=hotkey.desktop_name(), shortcut=current) if current
- else t("No global shortcut installed.")
- )
-
- def _install_meeting_shortcut(self):
- combo = self.meeting_shortcut.currentText().strip()
+ def _install_shortcut(self, which):
+ spec = hotkey.SHORTCUTS[which]
+ box, _status, _missing = self._shortcut_rows[which]
+ combo = box.currentText().strip() or spec.fallback
if not combo:
QMessageBox.information(self, t("Shortcut"),
t("Type a key combination first."))
return
- clashes = hotkey.conflicting_shortcuts(combo, hotkey.MEETING_DESKTOP_ID)
+ clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id)
if clashes:
answer = QMessageBox.question(
self, t("Shortcut conflict"),
@@ -1770,65 +1800,54 @@ class SettingsWindow(QDialog):
if answer != QMessageBox.StandardButton.Yes:
return
ok, message = hotkey.install_shortcut(
- combo, self.meeting_command, name="Dikte: start/end a meeting recording",
- desktop_id=hotkey.MEETING_DESKTOP_ID,
+ combo, ipc.command_for(spec.verb), name=spec.name,
+ desktop_id=spec.desktop_id,
)
QMessageBox.information(self, t("Shortcut"), message)
if ok:
- self.conf["meeting_shortcut"] = combo
+ self.conf[spec.setting] = combo
self.conf.save()
- self._refresh_meeting_shortcut_status()
+ self._refresh_shortcut_status(which)
- def _remove_meeting_shortcut(self):
- hotkey.remove_shortcut(hotkey.MEETING_DESKTOP_ID)
- self._refresh_meeting_shortcut_status()
+ def _remove_shortcut(self, which):
+ hotkey.remove_shortcut(hotkey.SHORTCUTS[which].desktop_id)
+ self._refresh_shortcut_status(which)
- def _refresh_meeting_shortcut_status(self):
- current = hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID)
- self.meeting_shortcut_status.setText(
+ def _refresh_shortcut_status(self, which):
+ _box, status, missing = self._shortcut_rows[which]
+ current = hotkey.shortcut_status(hotkey.SHORTCUTS[which].desktop_id)
+ status.setText(
t("Registered in {desktop}: {shortcut}",
desktop=hotkey.desktop_name(), shortcut=current) if current
- else t("No global shortcut installed. The tray menu starts a meeting too.")
+ else missing
)
- # ---- Claude ----------------------------------------------------------
-
- def _install_ask_shortcut(self):
- combo = self.assistant_shortcut.currentText().strip()
- if not combo:
- QMessageBox.information(self, t("Shortcut"),
- t("Type a key combination first."))
- return
- clashes = hotkey.conflicting_shortcuts(combo, hotkey.ASK_DESKTOP_ID)
- if clashes:
- answer = QMessageBox.question(
- self, t("Shortcut conflict"),
- t("{shortcut} is also used by:\n\n{list}\n\nInstall anyway?",
- shortcut=combo, list="\n".join(clashes[:6])),
- )
- if answer != QMessageBox.StandardButton.Yes:
- return
- ok, message = hotkey.install_shortcut(
- combo, self.ask_command, name="Dikte: ask Claude Code",
- desktop_id=hotkey.ASK_DESKTOP_ID,
- )
- QMessageBox.information(self, t("Shortcut"), message)
- if ok:
- self.conf["assistant_shortcut"] = combo
- self.conf.save()
- self._refresh_ask_shortcut_status()
-
- def _remove_ask_shortcut(self):
- hotkey.remove_shortcut(hotkey.ASK_DESKTOP_ID)
- self._refresh_ask_shortcut_status()
-
- def _refresh_ask_shortcut_status(self):
- current = hotkey.shortcut_status(hotkey.ASK_DESKTOP_ID)
- self.assistant_shortcut_status.setText(
- t("Registered in {desktop}: {shortcut}",
- desktop=hotkey.desktop_name(), shortcut=current) if current
- else t("No global shortcut installed. The tray menu asks it too.")
- )
+ def _cleanup_provider_changed(self):
+ provider = self.cleanup_provider.currentData() or "openrouter"
+ self.cleanup_form.setRowVisible(self.cleanup_model_row,
+ provider == "openrouter")
+ self.cleanup_form.setRowVisible(self.cleanup_claude_model,
+ provider == "claude")
+ self.cleanup_form.setRowVisible(self.cleanup_codex_model,
+ provider == "codex")
+ self.cleanup_form.setRowVisible(self.cleanup_reasoning,
+ provider != "local")
+ self.cleanup_form.setRowVisible(self.local_llm, provider == "local")
+ self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local")
+ binary = cleanup.executable(provider)
+ found = shutil.which(binary) if binary else ""
+ if provider == "local":
+ self.models_label.setText(t("Runs on this machine, on llama.cpp."))
+ elif not binary:
+ self.models_label.setText(t("Runs on OpenRouter."))
+ elif found:
+ self.models_label.setText(t("Found: {path}", path=found))
+ else:
+ self.models_label.setText(t(
+ "{binary} is not on your PATH, so cleanup would fail and the raw "
+ "transcript would be pasted. Install it, or pick another one "
+ "above.", binary=binary,
+ ))
def _assistant_provider_changed(self):
provider = self.assistant_provider.currentData() or "claude"
diff --git a/tests/__init__.py b/tests/__init__.py
index d422cec..e83e504 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -22,7 +22,7 @@ atexit.register(shutil.rmtree, _SANDBOX, True)
# A key sitting in the environment would otherwise reach the code that falls
# back to it, and the tests for "there is no key" would pass only on a machine
# without one.
-for _var in ("OPENAI_API_KEY", "OPENROUTER_API_KEY"):
+for _var in ("OPENAI_API_KEY", "GROQ_API_KEY", "OPENROUTER_API_KEY"):
os.environ.pop(_var, None)
# The interface language leaks through module-level state, so the tests fix it
diff --git a/tests/test_api.py b/tests/test_api.py
index 0a4e69c..6019061 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -22,24 +22,29 @@ from tests.support import (
)
OPENAI = api.Target("openai", "OpenAI", "sk-test", api.OPENAI_URL, "gpt-4o-transcribe")
+GROQ = api.Target("groq", "Groq", "gsk-test", api.GROQ_URL, "whisper-large-v3-turbo")
OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test",
api.OPENROUTER_URL, "openai/gpt-4o-transcribe")
class TimestampModel(unittest.TestCase):
def test_only_whisper_returns_segment_times(self):
- self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
- "whisper-1")
+ self.assertEqual(api.timestamp_model("openai"), "whisper-1")
def test_openrouter_namespaces_the_id(self):
- self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe"),
- "openai/whisper-1")
+ self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1")
- def test_the_local_server_stays_on_the_model_it_loaded(self):
- # Asking it for whisper-1 would name a model it has never heard of, and
- # it is running whisper whatever the file is called.
- self.assertEqual(api.timestamp_model("local", "ggml-base.bin"),
- "ggml-base.bin")
+ def test_groq_keeps_the_model_that_was_chosen(self):
+ """Every model it transcribes with is a whisper, so all of them do times."""
+ self.assertEqual(api.timestamp_model("groq", "whisper-large-v3"),
+ "whisper-large-v3")
+
+ def test_groq_with_nothing_chosen_falls_back(self):
+ self.assertEqual(api.timestamp_model("groq"), "whisper-large-v3-turbo")
+
+ def test_the_others_ignore_what_was_chosen(self):
+ self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
+ "whisper-1")
class Explain(DikteTest):
@@ -185,13 +190,28 @@ class Transcribe(DikteTest):
self.assertEqual(multipart_fields(calls[0])["language"], "tr")
self.assertNotIn("language", multipart_fields(calls[1]))
- def test_the_glossary_goes_to_openai_only(self):
+ def test_the_glossary_goes_everywhere_but_openrouter(self):
"""OpenRouter takes the field and throws it away, so spare it the bytes."""
with fake_urlopen({"text": "hi"}) as calls:
api.transcribe(OPENAI, self.wav, prompt="Paraşüt, OpenFrame")
+ api.transcribe(GROQ, self.wav, prompt="Paraşüt, OpenFrame")
api.transcribe(OPENROUTER, self.wav, prompt="Paraşüt, OpenFrame")
self.assertIn("prompt", multipart_fields(calls[0]))
- self.assertNotIn("prompt", multipart_fields(calls[1]))
+ self.assertIn("prompt", multipart_fields(calls[1]))
+ self.assertNotIn("prompt", multipart_fields(calls[2]))
+
+ def test_groq_goes_to_groq(self):
+ with fake_urlopen({"text": "hi"}) as calls:
+ api.transcribe(GROQ, self.wav)
+ self.assertEqual(calls[0].full_url,
+ "https://api.groq.com/openai/v1/audio/transcriptions")
+ self.assertEqual(multipart_fields(calls[0])["model"], "whisper-large-v3-turbo")
+
+ def test_a_refused_groq_key_is_explained_in_groq_s_name(self):
+ with fake_urlopen(http_error(401, '{"error": {"message": "bad key"}}')), \
+ self.assertRaises(api.ApiError) as caught:
+ api.transcribe(GROQ, self.wav)
+ self.assertIn("Groq", str(caught.exception))
def test_openrouter_is_attributed(self):
with fake_urlopen({"text": "hi"}) as calls:
@@ -251,6 +271,12 @@ class TranscribeSegments(DikteTest):
api.transcribe_segments(OPENROUTER, self.wav)
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1")
+ def test_groq_stays_on_the_model_it_was_given(self):
+ target = GROQ._replace(model="whisper-large-v3")
+ with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
+ api.transcribe_segments(target, self.wav)
+ self.assertEqual(multipart_fields(calls[0])["model"], "whisper-large-v3")
+
def test_the_segments_come_back_as_numbers(self):
with fake_urlopen(self.reply([
{"start": "0.5", "end": "2.25", "text": " hello "},
@@ -287,15 +313,10 @@ def chat_reply(content):
return {"choices": [{"message": {"content": content}}]}
-def openrouter(model="some/model", key="sk-or-test", reasoning="",
- base_url="https://openrouter.ai/api/v1"):
- return api.Target("openrouter", "OpenRouter", key, base_url, model, reasoning)
-
-
class Cleanup(DikteTest):
- def call(self, replies, target=None, **kwargs):
+ def call(self, replies, **kwargs):
with fake_urlopen(replies) as calls:
- result = api.cleanup(target or openrouter(), "uh, hello",
+ result = api.cleanup("uh, hello", "sk-or-test", "some/model",
"you clean up text", **kwargs)
return result, calls
@@ -325,34 +346,32 @@ class Cleanup(DikteTest):
self.assertNotIn("reasoning", sent_json(calls[0]))
def test_an_effort_is_passed_on_and_the_thinking_left_out(self):
- _, calls = self.call(chat_reply("Hello."),
- target=openrouter(reasoning="high"))
+ _, calls = self.call(chat_reply("Hello."), reasoning="high")
self.assertEqual(sent_json(calls[0])["reasoning"],
{"effort": "high", "exclude": True})
def test_a_local_base_url(self):
- _, calls = self.call(chat_reply("Hello."),
- target=openrouter(base_url="http://localhost:1234/v1"))
+ _, calls = self.call(chat_reply("Hello."), base_url="http://localhost:1234/v1")
self.assertEqual(calls[0].full_url, "http://localhost:1234/v1/chat/completions")
def test_no_key(self):
with self.assertRaises(api.ApiError):
- api.cleanup(openrouter(key=""), "hello", "prompt")
+ api.cleanup("hello", "", "some/model", "prompt")
def test_a_reply_with_no_choices_says_why(self):
with fake_urlopen({"error": {"message": "model is offline"}}), \
self.assertRaises(api.ApiError) as caught:
- api.cleanup(openrouter(), "hello", "p")
+ api.cleanup("hello", "k", "m", "p")
self.assertIn("model is offline", str(caught.exception))
def test_an_empty_answer(self):
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
- api.cleanup(openrouter(), "hello", "p")
+ api.cleanup("hello", "k", "m", "p")
def test_a_rate_limit_is_explained(self):
with fake_urlopen(http_error(429)), \
self.assertRaises(api.ApiError) as caught:
- api.cleanup(openrouter(), "hello", "p")
+ api.cleanup("hello", "k", "m", "p")
self.assertIn("OpenRouter", str(caught.exception))
@@ -448,6 +467,18 @@ class ModelLists(DikteTest):
with self.assertRaises(api.ApiError):
api.openai_models("")
+ def test_the_same_list_read_from_groq(self):
+ with fake_urlopen({"data": [{"id": "llama-3.3-70b"},
+ {"id": "whisper-large-v3"}]}) as calls:
+ models = api.openai_models("gsk-test", api.GROQ_URL, "Groq")
+ self.assertEqual(calls[0].full_url, "https://api.groq.com/openai/v1/models")
+ self.assertEqual(models, ["whisper-large-v3"])
+
+ def test_a_missing_groq_key_says_groq(self):
+ with self.assertRaises(api.ApiError) as caught:
+ api.openai_models("", api.GROQ_URL, "Groq")
+ self.assertIn("Groq", str(caught.exception))
+
if __name__ == "__main__":
unittest.main()
@@ -473,7 +504,6 @@ class FakeServer:
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
-LOCAL_LLM = api.Target("local-llm", "Local model", "", "", "gemma.gguf", "none")
class TranscribeHere(DikteTest):
@@ -548,62 +578,3 @@ class TranscribeHere(DikteTest):
with fake_urlopen({"segments": [{"start": 0, "end": 1, "text": " hi"}]}) as calls:
api.transcribe_segments(LOCAL, self.wav)
self.assertEqual(multipart_fields(calls[0])["model"], "ggml-base.bin")
-
-
-class CleanupHere(DikteTest):
- def setUp(self):
- super().setUp()
- self.server = FakeServer("http://127.0.0.1:8888/v1")
- self.patch_attr(ggml, "llm", self.server)
-
- def test_it_goes_to_the_server_it_starts(self):
- with fake_urlopen(chat_reply("Hello.")) as calls:
- result = api.cleanup(LOCAL_LLM, "uh, hello", "clean it up")
- self.assertEqual(result, "Hello.")
- self.assertEqual(calls[0].full_url,
- "http://127.0.0.1:8888/v1/chat/completions")
-
- def test_no_key_is_wanted_and_none_is_sent(self):
- with fake_urlopen(chat_reply("Hello.")) as calls:
- api.cleanup(LOCAL_LLM, "hello", "prompt")
- self.assertNotIn("Authorization", calls[0].headers)
-
- def test_thinking_is_turned_off_in_the_words_llama_cpp_uses(self):
- with fake_urlopen(chat_reply("Hello.")) as calls:
- api.cleanup(LOCAL_LLM, "hello", "prompt")
- self.assertEqual(sent_json(calls[0])["chat_template_kwargs"],
- {"enable_thinking": False})
-
- def test_the_models_own_default_asks_for_nothing(self):
- with fake_urlopen(chat_reply("Hello.")) as calls:
- api.cleanup(LOCAL_LLM._replace(reasoning=""), "hello", "prompt")
- self.assertNotIn("chat_template_kwargs", sent_json(calls[0]))
-
- def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
- reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
- with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
- api.cleanup(LOCAL_LLM, "hello", "prompt")
- self.assertIn("Thinking", str(caught.exception))
-
- def test_a_reply_longer_than_the_transcript_is_cut_off(self):
- # A small model will repeat the transcript until the context is full,
- # and every one of those tokens is a second of somebody waiting.
- with fake_urlopen(chat_reply("Hello.")) as calls:
- api.cleanup(LOCAL_LLM, "x" * 4000, "prompt")
- self.assertEqual(sent_json(calls[0])["max_tokens"], 4000)
-
- def test_a_short_dictation_still_gets_room_to_answer(self):
- with fake_urlopen(chat_reply("Hello.")) as calls:
- api.cleanup(LOCAL_LLM, "uh, hi", "prompt")
- self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
-
- def test_a_hosted_model_is_left_to_answer_at_length(self):
- with fake_urlopen(chat_reply("Hello.")) as calls:
- api.cleanup(openrouter(), "uh, hi", "prompt")
- self.assertNotIn("max_tokens", sent_json(calls[0]))
-
- def test_a_server_that_will_not_start_is_the_error_shown(self):
- self.patch_attr(ggml, "llm", FakeServer(fails="llama.cpp is not installed"))
- with self.assertRaises(api.ApiError) as caught:
- api.cleanup(LOCAL_LLM, "hello", "prompt")
- self.assertIn("llama.cpp", str(caught.exception))
diff --git a/tests/test_assistant.py b/tests/test_assistant.py
index 8fee724..c57cfbe 100644
--- a/tests/test_assistant.py
+++ b/tests/test_assistant.py
@@ -79,9 +79,12 @@ class Effort(unittest.TestCase):
self.assertEqual(assistant.CODEX_EFFORT["xhigh"], "high")
self.assertEqual(assistant.CODEX_EFFORT["max"], "high")
- def test_claude_has_no_rung_below_low(self):
- self.assertEqual(assistant.CLAUDE_EFFORT["none"], "low")
- self.assertEqual(assistant.CLAUDE_EFFORT["minimal"], "low")
+ def test_neither_one_asks_for_a_rung_below_low(self):
+ # Claude has none; Codex has one, but calls it "minimal" on the older
+ # models and "none" on the newer ones, and refuses the wrong word.
+ for scale in (assistant.CLAUDE_EFFORT, assistant.CODEX_EFFORT):
+ self.assertEqual(scale["none"], "low")
+ self.assertEqual(scale["minimal"], "low")
def test_an_empty_setting_asks_for_nothing(self):
self.assertEqual(assistant.CLAUDE_EFFORT.get("", ""), "")
@@ -232,10 +235,10 @@ class SessionMissing(unittest.TestCase):
self.assertFalse(assistant._session_missing(text))
def test_the_last_line_is_the_one_worth_showing(self):
- self.assertEqual(assistant._last_line("warning\n\nreal error\n"),
+ self.assertEqual(assistant.last_line("warning\n\nreal error\n"),
"real error")
- self.assertEqual(assistant._last_line(""), "")
- self.assertEqual(assistant._last_line(None), "")
+ self.assertEqual(assistant.last_line(""), "")
+ self.assertEqual(assistant.last_line(None), "")
class Conclude(DikteTest):
diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py
new file mode 100644
index 0000000..efb8417
--- /dev/null
+++ b/tests/test_cleanup.py
@@ -0,0 +1,286 @@
+"""Who cleans the transcript up, and what they are asked.
+
+The CLIs are faked at subprocess.run: what the tests read is the argument list
+each one is given, where the answer is picked up from, and what happens to the
+chain when the program is missing, slow or unhappy. The OpenRouter path is the
+one that was always there and is checked here only for still being taken.
+"""
+
+import os
+import subprocess
+import unittest
+from unittest import mock
+
+import api
+import cleanup
+import ggml
+from tests.support import DikteTest, fake_urlopen, sent_json, url_error
+from tests.test_api import FakeServer, chat_reply
+
+
+def fake_run(stdout="", code=0, stderr="", last_message=""):
+ """Stand in for subprocess.run, writing the file Codex would have written."""
+ calls = []
+
+ def run(cmd, **kwargs):
+ calls.append(cmd)
+ if last_message and "-o" in cmd:
+ with open(cmd[cmd.index("-o") + 1], "w", encoding="utf-8") as fh:
+ fh.write(last_message)
+ return subprocess.CompletedProcess(cmd, code, stdout, stderr)
+
+ return mock.patch.object(subprocess, "run", side_effect=run), calls
+
+
+class Provider(DikteTest):
+ def test_the_default_is_still_openrouter(self):
+ self.assertEqual(cleanup.provider(self.config()), "openrouter")
+
+ def test_a_provider_this_version_does_not_have(self):
+ self.assertEqual(
+ cleanup.provider(self.config(cleanup_provider="ollama")), "openrouter")
+
+ def test_each_one_is_recognised(self):
+ for name in cleanup.PROVIDERS:
+ with self.subTest(name=name):
+ self.assertEqual(
+ cleanup.provider(self.config(cleanup_provider=name)), name)
+
+ def test_what_each_one_runs(self):
+ self.assertEqual(cleanup.executable("claude"), "claude")
+ self.assertEqual(cleanup.executable("codex"), "codex")
+ self.assertEqual(cleanup.executable("openrouter"), "")
+
+ def test_the_model_named_in_the_history_is_the_one_that_did_it(self):
+ self.assertEqual(cleanup.model(self.config(cleanup_model="some/model")),
+ "some/model")
+ self.assertEqual(
+ cleanup.model(self.config(cleanup_provider="claude")), "haiku")
+ self.assertEqual(
+ cleanup.model(self.config(cleanup_provider="claude",
+ cleanup_claude_model="opus")), "opus")
+ # Codex on its own default has no model id to report, only a name.
+ self.assertEqual(
+ cleanup.model(self.config(cleanup_provider="codex")), "codex")
+ self.assertEqual(
+ cleanup.model(self.config(cleanup_provider="codex",
+ cleanup_codex_model="gpt-5.4")), "gpt-5.4")
+
+
+class OpenRouter(DikteTest):
+ def test_it_is_still_one_request_with_the_settings_as_they_were(self):
+ conf = self.config(openrouter_api_key="sk-or-test",
+ cleanup_model="some/model", cleanup_reasoning="low")
+ with mock.patch.object(api, "cleanup", return_value="Done.") as call:
+ self.assertEqual(cleanup.run("uh, done", conf, "the rules"), "Done.")
+ text, key, model, prompt = call.call_args.args
+ self.assertEqual((text, key, model, prompt),
+ ("uh, done", "sk-or-test", "some/model", "the rules"))
+ self.assertEqual(call.call_args.kwargs["reasoning"], "low")
+
+ def test_no_cli_is_started_for_it(self):
+ conf = self.config(openrouter_api_key="sk-or-test")
+ patcher, calls = fake_run(stdout="never")
+ with patcher, mock.patch.object(api, "cleanup", return_value="Done."):
+ cleanup.run("uh, done", conf, "the rules")
+ self.assertEqual(calls, [])
+
+
+class ClaudeCode(DikteTest):
+ def setUp(self):
+ super().setUp()
+ self.conf = self.config(cleanup_provider="claude")
+ self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
+
+ def run_cleanup(self, text="uh, book it", **kwargs):
+ patcher, calls = fake_run(**kwargs)
+ with patcher:
+ answer = cleanup.run(text, self.conf, "the rules")
+ return answer, calls[0]
+
+ def test_the_transcript_goes_in_fenced_and_the_rules_go_in_as_the_prompt(self):
+ answer, cmd = self.run_cleanup(stdout="Book it.\n")
+ self.assertEqual(answer, "Book it.")
+ self.assertEqual(cmd[0], "claude")
+ self.assertIn("\nuh, book it\n", cmd)
+ self.assertEqual(cmd[cmd.index("--system-prompt") + 1], "the rules")
+ self.assertEqual(cmd[cmd.index("--model") + 1], "haiku")
+
+ def test_it_is_given_nothing_to_run_and_nothing_to_remember(self):
+ _, cmd = self.run_cleanup(stdout="Book it.")
+ self.assertEqual(cmd[cmd.index("--tools") + 1], "")
+ self.assertIn("--strict-mcp-config", cmd)
+ self.assertIn("--no-session-persistence", cmd)
+
+ def test_the_thinking_setting_is_carried_over_in_its_own_words(self):
+ self.conf["cleanup_reasoning"] = "none"
+ _, cmd = self.run_cleanup(stdout="Book it.")
+ self.assertEqual(cmd[cmd.index("--effort") + 1], "low")
+
+ def test_no_thinking_setting_means_no_flag(self):
+ _, cmd = self.run_cleanup(stdout="Book it.")
+ self.assertNotIn("--effort", cmd)
+
+ def test_a_model_of_your_own(self):
+ self.conf["cleanup_claude_model"] = "claude-sonnet-5"
+ _, cmd = self.run_cleanup(stdout="Book it.")
+ self.assertEqual(cmd[cmd.index("--model") + 1], "claude-sonnet-5")
+
+ def test_an_answer_of_nothing_is_a_failure_rather_than_an_empty_paste(self):
+ with self.assertRaises(cleanup.CleanupError):
+ self.run_cleanup(stdout=" \n")
+
+ def test_the_last_line_of_the_complaint_is_what_gets_shown(self):
+ with self.assertRaises(cleanup.CleanupError) as caught:
+ self.run_cleanup(code=1, stderr="a warning\nout of credit\n")
+ self.assertEqual(str(caught.exception), "out of credit")
+
+ def test_a_failure_is_the_same_kind_the_chain_already_catches(self):
+ # worker, the file transcriber and the meeting all keep the raw
+ # transcript when an ApiError comes out of here.
+ self.assertTrue(issubclass(cleanup.CleanupError, api.ApiError))
+
+ def test_a_program_that_is_not_installed_says_so_before_running_anything(self):
+ self.patch_attr(cleanup.shutil, "which", lambda name: "")
+ with self.assertRaises(cleanup.CleanupError) as caught:
+ self.run_cleanup(stdout="Book it.")
+ self.assertIn("claude", str(caught.exception))
+
+ def test_a_run_that_never_ends(self):
+ def run(cmd, **kwargs):
+ raise subprocess.TimeoutExpired(cmd, 180)
+
+ with mock.patch.object(subprocess, "run", side_effect=run):
+ with self.assertRaises(cleanup.CleanupError) as caught:
+ cleanup.run("uh, book it", self.conf, "the rules")
+ self.assertIn("180", str(caught.exception))
+
+
+class Codex(DikteTest):
+ def setUp(self):
+ super().setUp()
+ self.conf = self.config(cleanup_provider="codex")
+ self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
+
+ def run_cleanup(self, text="uh, book it", **kwargs):
+ patcher, calls = fake_run(**kwargs)
+ with patcher:
+ answer = cleanup.run(text, self.conf, "the rules")
+ return answer, calls[0]
+
+ def test_the_rules_ride_in_front_of_the_transcript(self):
+ answer, cmd = self.run_cleanup(last_message="Book it.\n")
+ self.assertEqual(answer, "Book it.")
+ self.assertEqual(cmd[:2], ["codex", "exec"])
+ self.assertEqual(cmd[-1],
+ "the rules\n\n---\n\n\nuh, book it\n")
+
+ def test_the_answer_is_read_from_the_file_rather_than_the_noise_on_stdout(self):
+ answer, _ = self.run_cleanup(
+ stdout="workdir: /home\nmodel: gpt-5.4\ntokens used 400\n",
+ last_message="Book it.",
+ )
+ self.assertEqual(answer, "Book it.")
+
+ def test_that_file_does_not_stay_behind(self):
+ _, cmd = self.run_cleanup(last_message="Book it.")
+ self.assertFalse(os.path.exists(cmd[cmd.index("-o") + 1]))
+
+ def test_it_may_read_but_not_write_and_has_nobody_to_ask(self):
+ _, cmd = self.run_cleanup(last_message="Book it.")
+ self.assertEqual(cmd[cmd.index("--sandbox") + 1], "read-only")
+ self.assertIn('approval_policy="never"', cmd)
+ self.assertIn("--ephemeral", cmd)
+
+ def test_the_model_is_left_alone_until_one_is_typed_in(self):
+ _, cmd = self.run_cleanup(last_message="Book it.")
+ self.assertNotIn("-m", cmd)
+ self.conf["cleanup_codex_model"] = "gpt-5.4"
+ _, cmd = self.run_cleanup(last_message="Book it.")
+ self.assertEqual(cmd[cmd.index("-m") + 1], "gpt-5.4")
+
+ def test_the_thinking_setting_lands_on_the_nearest_rung_codex_has(self):
+ self.conf["cleanup_reasoning"] = "xhigh"
+ _, cmd = self.run_cleanup(last_message="Book it.")
+ self.assertIn('model_reasoning_effort="high"', cmd)
+
+ def test_an_answer_of_nothing(self):
+ with self.assertRaises(cleanup.CleanupError):
+ self.run_cleanup(stdout="tokens used 400", last_message="")
+
+
+if __name__ == "__main__":
+ unittest.main()
+
+
+class Here(DikteTest):
+ """llama.cpp, answering the request OpenRouter answers."""
+
+ def setUp(self):
+ super().setUp()
+ self.conf = self.config(cleanup_provider="local",
+ local_llm_model="gemma.gguf")
+ self.server = FakeServer()
+ self.patch_attr(ggml, "llm", self.server)
+
+ def test_the_address_comes_from_the_server_it_starts(self):
+ with fake_urlopen(chat_reply("Done.")) as calls:
+ self.assertEqual(cleanup.run("uh, done", self.conf, "the rules"),
+ "Done.")
+ self.assertEqual(self.server.starts, 1)
+ self.assertEqual(calls[0].full_url,
+ "http://127.0.0.1:9999/v1/chat/completions")
+
+ def test_no_key_is_wanted_and_none_is_sent(self):
+ with fake_urlopen(chat_reply("Done.")) as calls:
+ cleanup.run("uh, done", self.conf, "the rules")
+ self.assertNotIn("Authorization", calls[0].headers)
+
+ def test_thinking_is_turned_off_in_the_words_llama_cpp_uses(self):
+ with fake_urlopen(chat_reply("Done.")) as calls:
+ cleanup.run("uh, done", self.conf, "the rules")
+ self.assertEqual(sent_json(calls[0])["chat_template_kwargs"],
+ {"enable_thinking": False})
+
+ def test_the_models_own_default_asks_for_nothing(self):
+ self.conf["local_llm_reasoning"] = ""
+ with fake_urlopen(chat_reply("Done.")) as calls:
+ cleanup.run("uh, done", self.conf, "the rules")
+ self.assertNotIn("chat_template_kwargs", sent_json(calls[0]))
+
+ def test_a_reply_longer_than_the_transcript_is_cut_off(self):
+ # A small model will repeat the transcript until the context is full,
+ # and every one of those tokens is a second of somebody waiting.
+ with fake_urlopen(chat_reply("Done.")) as calls:
+ cleanup.run("x" * 4000, self.conf, "the rules")
+ self.assertEqual(sent_json(calls[0])["max_tokens"], 4000)
+
+ def test_a_short_dictation_still_gets_room_to_answer(self):
+ with fake_urlopen(chat_reply("Done.")) as calls:
+ cleanup.run("uh, done", self.conf, "the rules")
+ self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
+
+ def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
+ reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
+ with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
+ cleanup.run("uh, done", self.conf, "the rules")
+ self.assertIn("Thinking", str(caught.exception))
+
+ def test_a_server_that_will_not_start_is_the_error_shown(self):
+ self.patch_attr(ggml, "llm", FakeServer(fails="llama.cpp is not installed"))
+ with self.assertRaises(api.ApiError) as caught:
+ cleanup.run("uh, done", self.conf, "the rules")
+ self.assertIn("llama.cpp", str(caught.exception))
+
+ def test_a_server_that_dies_mid_request_says_what_it_printed(self):
+ self.patch_attr(ggml, "llm", FakeServer(log="out of memory"))
+ with fake_urlopen(url_error("connection reset")):
+ with self.assertRaises(api.ApiError) as caught:
+ cleanup.run("uh, done", self.conf, "the rules")
+ self.assertIn("out of memory", str(caught.exception))
+
+ def test_no_cli_is_started_for_it(self):
+ patcher, calls = fake_run(stdout="never")
+ with patcher, fake_urlopen(chat_reply("Done.")):
+ cleanup.run("uh, done", self.conf, "the rules")
+ self.assertEqual(calls, [])
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 98145c3..9547df2 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -14,8 +14,9 @@ from unittest import mock
import cli
import config as cfg
+import hotkey
import ipc
-from tests.support import DikteTest
+from tests.support import DikteTest, fake_urlopen
class Options:
@@ -144,6 +145,22 @@ class Parser(unittest.TestCase):
self.assertIsNone(opts.verb)
self.assertEqual(opts.func, cli.cmd_plain)
+ def test_every_global_shortcut_runs_a_verb_that_exists(self):
+ """A shortcut registers a command line; a verb the parser never heard of
+ is a key that does nothing at all when it is pressed."""
+ for name, spec in hotkey.SHORTCUTS.items():
+ with self.subTest(name=name):
+ opts = self.parse(spec.verb)
+ self.assertTrue(callable(opts.func))
+
+ def test_every_shortcut_can_be_installed_and_removed_by_name(self):
+ for name in hotkey.SHORTCUTS:
+ with self.subTest(name=name):
+ self.assertEqual(self.parse("shortcut", "install", name).which,
+ name)
+ self.assertEqual(self.parse("shortcut", "remove", name).which,
+ name)
+
def test_every_verb_is_wired_to_something(self):
for verb in ("record", "toggle", "start", "stop", "cancel", "ask",
"session", "transcribe", "meeting", "meetings", "history",
@@ -316,6 +333,67 @@ class ConfigCommands(DikteTest):
{"cleanup", "subtitles", "meeting", "agent"})
+class Providers(DikteTest):
+ """The terminal reaches every provider the settings window does."""
+
+ def run_cmd(self, func, **values):
+ with captured() as (out, err):
+ code = func(Options(**values))
+ return code, out.getvalue(), err.getvalue()
+
+ def test_a_provider_the_settings_window_offers_is_a_choice_here_too(self):
+ parser = cli.build_parser()
+ for provider in cfg.TRANSCRIBERS:
+ with self.subTest(provider=provider):
+ opts = parser.parse_args(["models", "--provider", provider])
+ self.assertEqual(opts.provider, provider)
+ self.assertEqual(parser.parse_args(["test-key", provider]).which,
+ provider)
+
+ def test_the_model_list_is_read_from_the_chosen_provider(self):
+ self.write_config({"groq_api_key": "gsk-test"})
+ with fake_urlopen({"data": [{"id": "whisper-large-v3"}]}) as calls:
+ code, out, _ = self.run_cmd(cli.cmd_models, provider="groq",
+ transcription=False)
+ self.assertEqual(code, 0)
+ self.assertEqual(calls[0].full_url, "https://api.groq.com/openai/v1/models")
+ self.assertEqual(out.strip(), "whisper-large-v3")
+
+ def test_a_key_that_is_not_there_is_reported_under_its_own_name(self):
+ code, out, _ = self.run_cmd(cli.cmd_test_key, which="groq")
+ self.assertEqual(code, 1)
+ self.assertIn("groq", out)
+ self.assertIn("Groq", out)
+
+
+class Doctor(DikteTest):
+ """One pass over everything the settings window checks behind its buttons."""
+
+ def run_doctor(self, as_json=True, **settings):
+ self.write_config(settings)
+ with mock.patch.object(ipc, "send", return_value=None), \
+ captured() as (out, _err):
+ cli.cmd_doctor(Options(json=as_json))
+ return json.loads(out.getvalue()) if as_json else out.getvalue()
+
+ def test_cleanup_on_openrouter_is_a_question_about_the_key(self):
+ reply = self.run_doctor(cleanup_model="some/model")
+ self.assertEqual(reply["cleanup"]["provider"], "openrouter")
+ self.assertEqual(reply["cleanup"]["model"], "some/model")
+ self.assertIn("OpenRouter key, cleaning up on some/model",
+ self.run_doctor(as_json=False, cleanup_model="some/model"))
+
+ def test_cleanup_on_a_cli_is_a_question_about_the_program(self):
+ reply = self.run_doctor(cleanup_provider="codex",
+ cleanup_codex_model="gpt-5.4")
+ self.assertEqual(reply["cleanup"]["provider"], "codex")
+ self.assertEqual(reply["cleanup"]["model"], "gpt-5.4")
+ self.assertIn("codex", reply["programs"])
+ self.assertIn("codex, cleaning up on gpt-5.4",
+ self.run_doctor(as_json=False, cleanup_provider="codex",
+ cleanup_codex_model="gpt-5.4"))
+
+
class Finding(DikteTest):
def test_no_history_at_all(self):
self.assertIsNone(cli._find_history("last"))
diff --git a/tests/test_config.py b/tests/test_config.py
index 28e3218..c41c503 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -12,6 +12,7 @@ import unittest
from unittest import mock
import api
+import cleanup
import config as cfg
import ggml
import i18n
@@ -126,6 +127,10 @@ class Keys(DikteTest):
def test_no_key_anywhere(self):
self.assertEqual(cfg.Config().openai_key(), "")
+ def test_every_provider_falls_back_to_the_variable_of_its_own_name(self):
+ with mock.patch.dict(os.environ, {"GROQ_API_KEY": "gsk-env"}):
+ self.assertEqual(cfg.Config().groq_key(), "gsk-env")
+
class TranscribeTarget(DikteTest):
def test_this_machine_by_default(self):
@@ -155,6 +160,21 @@ class TranscribeTarget(DikteTest):
self.assertEqual(target.api_key, "sk-or-test")
self.assertEqual(target.model, "openai/whisper-1")
+ def test_groq_when_it_is_picked(self):
+ conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test",
+ groq_transcribe_model="whisper-large-v3")
+ target = conf.transcribe_target()
+ self.assertEqual(target.provider, "groq")
+ self.assertEqual(target.service, "Groq")
+ self.assertEqual(target.api_key, "gsk-test")
+ self.assertEqual(target.base_url, api.GROQ_URL)
+ self.assertEqual(target.model, "whisper-large-v3")
+
+ def test_a_provider_this_version_has_never_heard_of(self):
+ """A config written by a fork, or by a version that dropped one."""
+ target = self.config(transcribe_provider="deepgram").transcribe_target()
+ self.assertEqual(target.provider, "openai")
+
def test_a_self_hosted_endpoint(self):
conf = self.config(transcribe_provider="openai",
openai_base_url="http://localhost:8080/v1")
@@ -438,31 +458,27 @@ if __name__ == "__main__":
unittest.main()
-class LocalTargets(DikteTest):
- def test_cleanup_can_run_here_while_the_minutes_do_not(self):
- # The two jobs are not the same size: a small model on this machine
- # strips filler words perfectly well and will not write up an hour.
- conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf")
- self.assertEqual(conf.cleanup_target().provider, "local-llm")
- self.assertEqual(conf.minutes_target().provider, "openrouter")
- self.assertEqual(conf.minutes_target().model, cfg.DEFAULTS["meeting_model"])
+class LocalCleanup(DikteTest):
+ def test_the_local_model_is_what_the_history_records(self):
+ conf = self.config(cleanup_provider="local",
+ local_llm_model="gemma-3-4b-it-Q4_K_M.gguf")
+ self.assertEqual(cleanup.provider(conf), "local")
+ self.assertEqual(cleanup.model(conf), "gemma-3-4b-it-Q4_K_M.gguf")
- def test_the_minutes_can_run_here_on_their_own(self):
- conf = self.config(meeting_provider="local", local_llm_model="gemma.gguf")
- self.assertEqual(conf.minutes_target().model, "gemma.gguf")
- self.assertEqual(conf.cleanup_target().provider, "openrouter")
+ def test_it_needs_no_program_on_the_path(self):
+ # whisper.cpp and llama.cpp are fetched rather than installed, so unlike
+ # Claude Code and Codex there is no executable to look for.
+ self.assertEqual(cleanup.executable("local"), "")
- def test_the_local_cleanup_target_carries_the_thinking_level(self):
- conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf",
- local_llm_reasoning="none")
- target = conf.cleanup_target()
- self.assertEqual(target.reasoning, "none")
- self.assertEqual(target.api_key, "")
+ def test_the_minutes_do_not_follow_the_cleanup_provider(self):
+ # A 4B model here will strip the filler words out of a dictation and
+ # will not write up an hour long meeting.
+ conf = self.config(cleanup_provider="local")
+ self.assertEqual(conf["meeting_model"], cfg.DEFAULTS["meeting_model"])
- def test_either_of_them_counts_as_using_the_local_model(self):
+ def test_only_the_cleanup_setting_asks_for_the_local_model(self):
self.assertFalse(cfg.Config().uses_local_llm())
self.assertTrue(self.config(cleanup_provider="local").uses_local_llm())
- self.assertTrue(self.config(meeting_provider="local").uses_local_llm())
class ReadyToRun(DikteTest):
diff --git a/tests/test_filetranscribe.py b/tests/test_filetranscribe.py
index 5ed22d6..def4f5c 100644
--- a/tests/test_filetranscribe.py
+++ b/tests/test_filetranscribe.py
@@ -197,7 +197,7 @@ class Transcriber(DikteTest):
def test_cleanup_is_told_it_is_writing_subtitles(self):
_, _, _, cleanup_call = self.run_chain(cleanup=True)
- prompt = cleanup_call.call_args.args[2]
+ prompt = cleanup_call.call_args.args[3]
self.assertEqual(prompt, self.conf.cleanup_prompt(subtitles=True))
def test_timestamps_come_back_as_segments_and_as_stamped_lines(self):
diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py
index 4297724..01ae92d 100644
--- a/tests/test_hotkey.py
+++ b/tests/test_hotkey.py
@@ -6,6 +6,7 @@ import subprocess
import unittest
from unittest import mock
+import config as cfg
import hotkey
from tests.support import DikteTest, FakeCompleted, linux_only
@@ -56,6 +57,28 @@ class ParseShortcut(unittest.TestCase):
self.assertEqual(hotkey.parse_shortcut(None), (None, None))
+class Table(unittest.TestCase):
+ """The one list of global shortcuts. The command line, the settings window
+ and install.sh read it instead of keeping a copy each, so what it has to
+ hold together is checked here rather than in three places."""
+
+ def test_every_shortcut_remembers_itself_in_a_real_setting(self):
+ for name, spec in hotkey.SHORTCUTS.items():
+ with self.subTest(name=name):
+ self.assertIn(spec.setting, cfg.DEFAULTS)
+
+ def test_no_two_share_a_desktop_entry(self):
+ ids = [spec.desktop_id for spec in hotkey.SHORTCUTS.values()]
+ self.assertEqual(len(ids), len(set(ids)))
+
+ def test_only_the_toggle_falls_back_to_a_key_of_its_own(self):
+ """The rest are off until you pick one, and emptying the box is how you
+ turn them off again."""
+ self.assertEqual(hotkey.SHORTCUTS["toggle"].fallback, "Ctrl+Space")
+ self.assertEqual([name for name, spec in hotkey.SHORTCUTS.items()
+ if spec.fallback], ["toggle"])
+
+
class ModsMatch(unittest.TestCase):
"""The combination has to be exact, or Ctrl+Space fires on Ctrl+Shift+Space."""
@@ -122,6 +145,23 @@ class Bindings(DikteTest):
thread.assert_called_once()
self.assertEqual(len(listener._bindings[57]), 2)
+ def test_starting_and_discarding_do_not_fire_on_each_other(self):
+ """The two defaults are one modifier apart on the same key code, so the
+ modifier set is the only thing keeping them apart."""
+ listener = hotkey.EvdevHotkey()
+ self.addCleanup(listener.stop)
+ with mock.patch.object(listener, "_open_devices", return_value=[99]), \
+ mock.patch.object(hotkey.threading, "Thread"):
+ listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Alt+Space"})
+
+ def fired(held):
+ return [name for mods, name in listener._bindings[57]
+ if hotkey.EvdevHotkey._mods_match(held, mods)]
+
+ self.assertEqual(fired({29}), ["toggle"]) # ctrl
+ self.assertEqual(fired({29, 56}), ["cancel"]) # ctrl + alt
+ self.assertEqual(fired({29, 42}), []) # ctrl + shift
+
@linux_only
class Chooser(DikteTest):
diff --git a/tests/test_ui.py b/tests/test_ui.py
index b2aa933..3ba334c 100644
--- a/tests/test_ui.py
+++ b/tests/test_ui.py
@@ -11,7 +11,9 @@ from unittest import mock
from PyQt6.QtWidgets import QApplication, QMessageBox
+import cleanup
import config as cfg
+import hotkey
import overlay as overlay_module
import settings_ui
from tests.support import DikteTest, only_these_tools
@@ -36,13 +38,17 @@ CHANGED = {
"filter_hallucinations": False,
"keep_audio": True,
"openai_api_key": "sk-test-key",
+ "groq_api_key": "gsk-test-key",
"openrouter_api_key": "sk-or-test-key",
"transcribe_provider": "openrouter",
"transcribe_model": "whisper-1",
+ "groq_transcribe_model": "whisper-large-v3",
"openrouter_transcribe_model": "openai/whisper-1",
"cleanup_enabled": False,
"cleanup_provider": "local",
"cleanup_model": "some/other-model",
+ "cleanup_claude_model": "opus",
+ "cleanup_codex_model": "gpt-5",
"cleanup_reasoning": "high",
"local_model": "ggml-small.bin",
"local_gpu": False,
@@ -84,6 +90,7 @@ CHANGED = {
"file_timestamps": True,
"file_cleanup": False,
"shortcut": "Ctrl+Alt+Space",
+ "cancel_shortcut": "Meta+Shift+Space",
"evdev_hotkey": True,
"history_limit": 50,
}
@@ -106,7 +113,7 @@ class Settings(DikteTest):
self.path("kglobalshortcutsrc")))
def window(self, conf):
- window = settings_ui.SettingsWindow(conf, "dikte toggle")
+ window = settings_ui.SettingsWindow(conf)
self.addCleanup(window.deleteLater)
self.addCleanup(window.close)
return window
@@ -134,6 +141,20 @@ class Settings(DikteTest):
with self.subTest(key=key):
self.assertEqual(stored[key], value)
+ def test_the_model_box_on_screen_belongs_to_whoever_cleans_up(self):
+ """An OpenRouter id and a Claude alias are not the same field."""
+ window = self.window(cfg.Config())
+ boxes = {"openrouter": window.cleanup_model_row,
+ "claude": window.cleanup_claude_model,
+ "codex": window.cleanup_codex_model}
+ for provider, box in boxes.items():
+ with self.subTest(provider=provider):
+ window._select_data(window.cleanup_provider, provider)
+ shown = [name for name, other in boxes.items()
+ if not other.isHidden()]
+ self.assertEqual(shown, [provider])
+ self.assertFalse(box.isHidden())
+
def test_the_settings_the_window_does_not_show_are_left_alone(self):
"""A tab nobody wrote must not reset what the command line set."""
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
@@ -144,6 +165,37 @@ class Settings(DikteTest):
self.assertEqual(stored["speech_margin_db"], 15.0)
self.assertEqual(stored["openrouter_base_url"], "http://localhost:1234/v1")
+ def test_every_global_shortcut_has_a_row_of_its_own(self):
+ window = self.window(cfg.Config())
+ self.assertEqual(set(window._shortcut_rows), set(hotkey.SHORTCUTS))
+
+ def test_emptying_a_shortcut_turns_it_off_but_not_the_toggle(self):
+ """The application is unusable without the toggle, so that one box
+ falls back. The rest stay empty, which is how they are switched off."""
+ conf = cfg.Config()
+ window = self.window(conf)
+ for box, _status, _missing in window._shortcut_rows.values():
+ box.setCurrentText("")
+ window._save()
+ self.assertEqual(conf["shortcut"], "Ctrl+Space")
+ self.assertEqual(conf["cancel_shortcut"], "")
+ self.assertEqual(conf["assistant_shortcut"], "")
+ self.assertEqual(conf["meeting_shortcut"], "")
+
+ def test_installing_the_discard_key_writes_its_own_entry(self):
+ conf = cfg.Config()
+ window = self.window(conf)
+ window._shortcut_rows["cancel"][0].setCurrentText("Meta+Shift+Space")
+ with mock.patch.object(settings_ui.hotkey, "install_shortcut",
+ return_value=(True, "saved")) as install:
+ window._install_shortcut("cancel")
+ combo, command = install.call_args.args
+ self.assertEqual(combo, "Meta+Shift+Space")
+ self.assertTrue(command.endswith(" cancel"))
+ self.assertEqual(install.call_args.kwargs["desktop_id"],
+ hotkey.CANCEL_DESKTOP_ID)
+ self.assertEqual(conf["cancel_shortcut"], "Meta+Shift+Space")
+
def test_a_prompt_left_at_its_default_is_stored_as_empty(self):
"""So that switching the interface language switches the prompt too."""
conf = cfg.Config()
@@ -155,14 +207,44 @@ class Settings(DikteTest):
def test_each_provider_keeps_its_own_transcription_model(self):
self.write_config({"transcribe_provider": "openai",
"transcribe_model": "gpt-4o-transcribe",
+ "groq_transcribe_model": "whisper-large-v3",
"openrouter_transcribe_model": "openai/whisper-1"})
conf = cfg.Config()
window = self.window(conf)
- window.transcribe_provider.setCurrentIndex(
- window.transcribe_provider.findData("openrouter"))
+ for provider in ("groq", "openrouter"):
+ window.transcribe_provider.setCurrentIndex(
+ window.transcribe_provider.findData(provider))
window._save()
self.assertEqual(conf["transcribe_provider"], "openrouter")
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
+ self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3")
+
+ def test_the_provider_box_offers_every_provider_config_knows(self):
+ window = self.window(cfg.Config())
+ offered = [window.transcribe_provider.itemData(i)
+ for i in range(window.transcribe_provider.count())]
+ self.assertEqual(offered, ["local"] + list(cfg.TRANSCRIBERS))
+
+ def test_the_cleanup_box_offers_everyone_cleanup_py_dispatches_to(self):
+ window = self.window(cfg.Config())
+ offered = [window.cleanup_provider.itemData(i)
+ for i in range(window.cleanup_provider.count())]
+ self.assertEqual(sorted(offered), sorted(cleanup.PROVIDERS))
+
+ def test_the_answer_to_a_test_lands_under_the_key_it_was_asked_about(self):
+ """One signal serves all three buttons, so it carries which one asked."""
+ window = self.window(cfg.Config())
+ window._on_test_done("groq", True, "it works")
+ button, answer = window._testers["groq"]
+ self.assertEqual(answer.text(), "✓ it works")
+ self.assertTrue(button.isEnabled())
+ self.assertEqual(window._testers["openai"][1].text(), "")
+
+ def test_a_key_lands_in_the_field_of_its_own_provider(self):
+ self.write_config({"groq_api_key": "gsk-mine"})
+ window = self.window(cfg.Config())
+ self.assertEqual(window.groq_key.text(), "gsk-mine")
+ self.assertEqual(window.openai_key.text(), "")
def test_saving_applies_the_lowered_history_limit_at_once(self):
for index in range(10):
@@ -275,7 +357,7 @@ class LocalModels(DikteTest):
"""The download boxes, without a network and without either program."""
def window(self, conf):
- window = settings_ui.SettingsWindow(conf, "dikte toggle")
+ window = settings_ui.SettingsWindow(conf)
self.addCleanup(window.deleteLater)
self.addCleanup(window.close)
return window
@@ -327,17 +409,20 @@ class LocalModels(DikteTest):
for row in range(box.repo.count()))
self.assertGreaterEqual(view.minimumWidth(), widest)
- def test_the_hosted_boxes_go_away_when_the_work_happens_here(self):
+ def test_only_the_chosen_transcriber_is_on_screen(self):
window = self.window(self.config(transcribe_provider="openai"))
- self.assertTrue(window.hosted_stt.isVisibleTo(window))
- self.assertFalse(window.local_whisper.isVisibleTo(window))
+ self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row))
+ self.assertFalse(window.stt_form.isRowVisible(window.local_whisper))
window._select_data(window.transcribe_provider, "local")
- self.assertFalse(window.hosted_stt.isVisibleTo(window))
- self.assertTrue(window.local_whisper.isVisibleTo(window))
+ self.assertFalse(window.stt_form.isRowVisible(window.transcribe_model_row))
+ self.assertTrue(window.stt_form.isRowVisible(window.local_whisper))
- def test_the_same_for_cleanup(self):
+ def test_only_the_chosen_cleaner_is_on_screen(self):
window = self.window(cfg.Config())
- self.assertTrue(window.hosted_cleanup.isVisibleTo(window))
+ self.assertTrue(window.cleanup_form.isRowVisible(window.cleanup_model_row))
+ self.assertFalse(window.cleanup_form.isRowVisible(window.local_llm))
window._select_data(window.cleanup_provider, "local")
- self.assertTrue(window.local_llm.isVisibleTo(window))
- self.assertFalse(window.hosted_cleanup.isVisibleTo(window))
+ self.assertTrue(window.cleanup_form.isRowVisible(window.local_llm))
+ self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_model_row))
+ # Its own thinking box, because the two default to opposite things.
+ self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning))
diff --git a/uninstall.sh b/uninstall.sh
new file mode 100755
index 0000000..dfbdaa1
--- /dev/null
+++ b/uninstall.sh
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+# Dikte uninstaller: takes back what install.sh put down, and nothing else
+# unless asked. Your settings and your dictations survive a plain run; --purge
+# is the word that deletes them.
+set -euo pipefail
+
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PY="$(command -v python3 || true)"
+USER_NAME="$(id -un)"
+BIN_DIR="$HOME/.local/bin"
+APP_DIR="$HOME/.local/share/applications"
+AUTOSTART_DIR="$HOME/.config/autostart"
+CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte"
+DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte"
+
+PURGE=0
+ASSUME_YES=0
+
+say() { printf ' %s\n' "$1"; }
+ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
+warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
+gone() { printf ' \033[90m·\033[0m %s\n' "$1"; }
+# "1 dictation", "3 dictations": how many is the point of printing it at all.
+count() {
+ if (( $1 == 1 )); then printf '%s %s' "$1" "$2"; else printf '%s %s' "$1" "$3"; fi
+}
+
+usage() {
+ cat <&2; usage >&2; exit 2 ;;
+ esac
+done
+
+# A symlink whose target is gone is still a file to remove, hence -L.
+remove() {
+ if [[ -e "$1" || -L "$1" ]]; then
+ rm -f "$1"
+ ok "Removed $1"
+ else
+ gone "Was not there: $1"
+ fi
+}
+
+echo
+echo "Uninstalling Dikte"
+echo "──────────────────"
+
+# 1. Global shortcuts ------------------------------------------------------
+# Handed to Dikte while it can still run, because it is the half that knows
+# whether they went into KDE's kglobalshortcutsrc or GNOME's gsettings.
+if [[ -n "$PY" ]] && python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then
+ for which in toggle cancel ask meeting; do
+ "$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true
+ done
+ ok "Global shortcuts unregistered"
+ say "KWin reads that file at startup, so the keys are free after your next login."
+else
+ warn "PyQt6 is missing, so the shortcuts were left registered."
+ say "Remove them in your desktop's shortcut settings."
+fi
+
+# 2. The running instance --------------------------------------------------
+# It holds a tray icon and a socket; asking it to quit is tidier than pulling
+# its launchers out from under it.
+if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
+ [[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" quit >/dev/null 2>&1 || true
+ sleep 0.5
+ if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
+ warn "Dikte is still running; close it from the tray icon"
+ else
+ ok "Stopped the running instance"
+ fi
+fi
+
+# 3. Launchers -------------------------------------------------------------
+# Only our own symlink goes: a file of the same name that somebody else put
+# there is not ours to delete.
+if [[ -L "$BIN_DIR/dikte" ]]; then
+ remove "$BIN_DIR/dikte"
+elif [[ -e "$BIN_DIR/dikte" ]]; then
+ warn "$BIN_DIR/dikte is not our symlink, leaving it alone"
+else
+ gone "Was not there: $BIN_DIR/dikte"
+fi
+remove "$APP_DIR/dikte.desktop"
+remove "$AUTOSTART_DIR/dikte.desktop"
+# Removing the shortcut takes its desktop file with it, but an install from
+# before this script existed may have left one behind on a desktop that never
+# used them.
+for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do
+ if [[ -e "$APP_DIR/$id.desktop" ]]; then
+ remove "$APP_DIR/$id.desktop"
+ fi
+done
+
+# 4. Settings and dictations -----------------------------------------------
+echo
+if ((PURGE)); then
+ warn "--purge also deletes:"
+ if [[ -f "$CONFIG_DIR/config.json" ]]; then
+ say "$CONFIG_DIR/config.json (your API keys and every setting)"
+ fi
+ if [[ -f "$DATA_DIR/history.jsonl" ]]; then
+ # grep -c rather than wc -l: a last line with no newline is still a dictation.
+ say "$DATA_DIR/history.jsonl ($(count "$(grep -c '' "$DATA_DIR/history.jsonl" 2>/dev/null || echo 0)" dictation dictations))"
+ fi
+ if [[ -d "$DATA_DIR/meetings" ]]; then
+ say "$DATA_DIR/meetings ($(count "$(find "$DATA_DIR/meetings" -name '*.md' | wc -l)" meeting meetings))"
+ fi
+ if [[ -d "$DATA_DIR/recordings" ]]; then
+ say "$DATA_DIR/recordings ($(du -sh "$DATA_DIR/recordings" | cut -f1) of audio)"
+ fi
+
+ if ((!ASSUME_YES)); then
+ if [[ -t 0 ]]; then
+ printf ' Type yes to delete them: '
+ read -r reply
+ [[ "$reply" == "yes" ]] || { PURGE=0; say "Kept."; }
+ else
+ PURGE=0
+ warn "Not a terminal, so nothing was deleted. Pass --yes if you meant it."
+ fi
+ fi
+fi
+
+if ((PURGE)); then
+ rm -rf "$CONFIG_DIR" "$DATA_DIR"
+ ok "Settings and dictations deleted"
+else
+ say "Settings kept: $CONFIG_DIR"
+ say "Dictations kept: $DATA_DIR"
+ say "Delete them too with: ./uninstall.sh --purge"
+fi
+
+echo
+ok "Done."
+say "The source directory is untouched: $DIR"
+echo
diff --git a/update.sh b/update.sh
new file mode 100755
index 0000000..9194407
--- /dev/null
+++ b/update.sh
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+# Dikte updater: pull, put the launchers back, restart what was running.
+set -euo pipefail
+
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PY="$(command -v python3 || true)"
+USER_NAME="$(id -un)"
+
+say() { printf ' %s\n' "$1"; }
+ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
+warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
+die() { printf ' \033[31m✗\033[0m %s\n' "$1"; echo; exit 1; }
+
+# The combination stored in the settings, which is where Dikte itself reads it
+# from and the one place that is the same on KDE and on GNOME.
+setting() {
+ [[ -n "$PY" ]] || return 0
+ "$PY" "$DIR/dikte.py" config get "$1" 2>/dev/null || true
+}
+
+echo
+echo "Updating Dikte"
+echo "──────────────"
+
+cd "$DIR"
+
+# 1. Somewhere there is something to pull ----------------------------------
+command -v git >/dev/null || die "git not found; update by downloading the source again"
+git rev-parse --git-dir >/dev/null 2>&1 \
+ || die "$DIR is not a git checkout; update by downloading the source again"
+
+before="$(git rev-parse HEAD)"
+
+# 2. Is there anything to come? ---------------------------------------------
+# Asked before anything else is complained about: an unfinished afternoon in
+# the working tree is nobody's problem on a day when nothing has been
+# published. Fetching leaves the working tree alone.
+git fetch --quiet || die "Could not reach the remote."
+upstream="$(git rev-parse '@{u}' 2>/dev/null)" \
+ || die "This branch is not tracking a remote one; pull by hand."
+
+if [[ "$before" == "$upstream" ]]; then
+ echo
+ ok "Already up to date ($(git log -1 --format=%s))"
+ echo
+ exit 0
+fi
+
+# 3. Only now, your own edits -----------------------------------------------
+# They would be overwritten by a fast-forward or would block it, and either way
+# that is your call to make, not this script's. Untracked files are counted
+# too: a fast-forward that adds a file of that name stops on them.
+if [[ -n "$(git status --porcelain)" ]]; then
+ warn "There is an update waiting, but you have changes of your own here:"
+ git --no-pager status --short | sed 's/^/ /'
+ say "Commit them, or put them aside with: git stash --include-untracked"
+ die "Nothing was updated."
+fi
+
+# --ff-only: an update should be somebody else's commits arriving, never a
+# merge this script decided to make on your behalf. The fetch above already
+# brought them, so this touches no network.
+# advice off: git's suggestion is a merge or a rebase, and which of those you
+# want is the sentence below, not a wall of hints.
+if ! merge_log="$(git -c advice.diverging=false merge --ff-only '@{u}' 2>&1)"; then
+ printf '%s\n' "$merge_log" | sed 's/^/ /'
+ say "Your branch has commits the remote does not. To put them on top of the"
+ say "update instead: git pull --rebase"
+ die "Could not fast-forward."
+fi
+after="$(git rev-parse HEAD)"
+
+echo
+say "What arrived:"
+git --no-pager log --oneline "$before..$after" | sed 's/^/ /'
+echo
+
+# 4. Launchers --------------------------------------------------------------
+# An update can add a dependency or move a file, so the installer runs again.
+# It would otherwise register its own defaults over the keys you chose, so it
+# is told what those are. Read before the installer runs, since it is the one
+# writing them.
+shortcut="$(setting shortcut)"
+cancel_shortcut="$(setting cancel_shortcut)"
+# Positional, so a chosen discard key cannot be passed without the other one.
+"$DIR/install.sh" "${shortcut:-Ctrl+Space}" "${cancel_shortcut:-}"
+
+# 5. The running instance ---------------------------------------------------
+# It is still running the code from before the pull.
+if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
+ if [[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" restart >/dev/null 2>&1; then
+ ok "Restarted, so the new version is the one running"
+ else
+ warn "Could not restart it; use the tray menu → Restart"
+ fi
+else
+ say "Dikte was not running. Start it with: dikte"
+fi
+echo
diff --git a/worker.py b/worker.py
index 69871ab..012ab07 100644
--- a/worker.py
+++ b/worker.py
@@ -18,6 +18,7 @@ from PyQt6.QtCore import QObject, pyqtSignal
import api
import assistant
import audio
+import cleanup
import config as cfg
import i18n
import paste
@@ -106,14 +107,13 @@ class Pipeline(QObject):
text = raw
warning = ""
- cleaner = conf.cleanup_target()
# Claude reads through “eee” and “hani” without help, so a dictation
# on its way there is normally sent as it was heard, one API call and
# a second or two lighter.
if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]):
self.stage.emit(t("Cleaning up…"))
try:
- text = api.cleanup(cleaner, raw, conf.cleanup_prompt())
+ text = cleanup.run(raw, conf, conf.cleanup_prompt())
except api.ApiError as exc:
# Keep the transcript, but never let the failure pass unseen:
# a rejected key would otherwise look like working dictation.
@@ -153,7 +153,7 @@ class Pipeline(QObject):
"duration": round(duration, 1),
"elapsed": round(time.monotonic() - started, 1),
"model": target.model,
- "cleanup_model": cleaner.model if conf["cleanup_enabled"] else "",
+ "cleanup_model": cleanup.model(conf) if conf["cleanup_enabled"] else "",
"cleanup_error": warning,
"mode": "ask" if ask else "",
"question": question,