From 18124616120842a75221c87696928719e27107ed Mon Sep 17 00:00:00 2001 From: oztturk Date: Wed, 26 Aug 2026 16:23:04 +0300 Subject: [PATCH 1/6] Clean up on Google AI Studio, or on Antigravity OpenRouter's free tier rate-limits and carries no free Gemini model, and cleaning up through Claude Code costs a fixed few seconds because it opens a whole CLI session to drop three "uh"s. Google's own free tier suits a short, frequent request, and its OpenAI-compatible endpoint answers /chat/completions, so cleanup there is one request and the same code path OpenRouter already takes. The one thing that is not shared is the thinking level. Google reads OpenAI's flat reasoning_effort rather than OpenRouter's object, and "none" is how thinking is turned off, so it is sent rather than skipped: a Flash model left to think spends exactly the second this provider was chosen to save. Its top two rungs land on "high", which is as far as Google goes. Speech to text stays where it was. That endpoint has no /audio/transcriptions behind it, audio only goes in as base64 inside a chat message, and what comes back has none of the segment times a subtitle file or a meeting transcript is built out of. Antigravity joins as well, on cleanup and as an agent. It is a CLI like the other two and costs the same session, so it is here for people who already pay for it rather than as an answer to the speed. It takes neither an empty tool list nor a read-only sandbox, and cleanup.py now says so plainly instead of implying parity; what it gets is a project of its own, the home directory, and its slash commands off. Three things were already wrong and are fixed on the way past, because the new providers walk the same paths: doctor raised KeyError on the local model, whose executable is ""; the history recorded Claude's model whoever answered; and every agent row read "asked Claude". Co-Authored-By: Claude Opus 5 --- README.md | 25 +++--- README.tr.md | 21 +++-- dikte/api.py | 74 +++++++++++++--- dikte/assistant.py | 148 +++++++++++++++++++++++++++++--- dikte/cleanup.py | 65 ++++++++++++-- dikte/cli.py | 43 +++++++--- dikte/config.py | 12 ++- dikte/i18n.py | 54 ++++++++---- dikte/settings_ui.py | 161 ++++++++++++++++++++++++++++++----- dikte/worker.py | 3 +- tests/test_api.py | 65 ++++++++++++++ tests/test_assistant.py | 184 ++++++++++++++++++++++++++++++++++------ tests/test_cleanup.py | 109 ++++++++++++++++++++++++ tests/test_cli.py | 21 +++++ tests/test_config.py | 8 ++ tests/test_ui.py | 30 +++++++ 16 files changed, 901 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index fe36166..af5fe9c 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,12 @@ or runs it on the spot. Speech to text and cleanup each pick a provider in the settings window, and both run here by default, on models of your own. The cloud is the other option: speech to text on **OpenAI**, **Groq** or **OpenRouter** (`gpt-4o-transcribe`), -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 +cleanup on OpenRouter (`google/gemini-3.5-flash-lite`), on **Google AI Studio** +(`gemini-3.5-flash-lite`) or, when one of them is installed, on Claude Code, +Codex or Antigravity. The first two are a single HTTP request; the three CLIs +each open a whole session to do it, which is where their few extra seconds go. +The keys fall back to `OPENAI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY` +and `GEMINI_API_KEY`, and are stored in `~/.config/dikte/config.json`, mode 600, or in `~/Library/Application Support/Dikte` on a Mac. Cleanup can be switched off, in which case the raw transcript is pasted, and a thinking model's effort can be @@ -188,11 +191,13 @@ running. what comes of it: the answer, or a sentence saying what was done. It is the session you would have opened yourself, so your skills and connected services are there, which is what makes "put that in my calendar on Thursday at three" - a thing you can say to a window that is not Claude. Codex (`codex exec`) runs - the same way, and OpenRouter is there as a plain question-and-answer fallback - for a machine with neither CLI on it. Provider, model, permissions and working - directory are under Settings → Agent, and commands close together stay in one - conversation. + a thing you can say to a window that is not Claude. Codex (`codex exec`) and + Antigravity (`agy -p`) run the same way, though Antigravity takes neither a + permission mode nor a sandbox from Dikte: what it may do without asking is + whatever its own allow-rules say. OpenRouter is there as a plain + question-and-answer fallback for a machine with no CLI on it. Provider, model, + permissions and working directory are under Settings → Agent, and commands + close together stay in one conversation. - **Meetings** are recorded from the microphone and the speaker output at the same time, which settles who said what by the channel a voice arrived on instead of guessing at it. The two sides are transcribed separately and @@ -239,9 +244,9 @@ cli.py the command line: every verb, and what it answers with 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 +assistant.py handing a dictation to Claude Code, Codex, agy or OpenRouter api.py transcription and cleanup requests (stdlib only) -cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex +cleanup.py who rewrites the transcript: a hosted model, one here, a CLI ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving hub.py what GitHub and Hugging Face have on offer today update.py whether a newer release is out, and the page it is on diff --git a/README.tr.md b/README.tr.md index 5e88c69..b5869ff 100644 --- a/README.tr.md +++ b/README.tr.md @@ -112,10 +112,13 @@ Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıc seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. 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, Mac'te ise `~/Library/Application Support/Dikte` altında. +(`google/gemini-3.5-flash-lite`), **Google AI Studio**'da +(`gemini-3.5-flash-lite`) ya da kuruluysa Claude Code, Codex veya +Antigravity'de çalışır. İlk ikisi tek bir HTTP isteği; üç CLI ise bunun için +birer oturum açar, fazladan giden birkaç saniye de oradan gelir. Anahtarları boş +bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY` ve +`GEMINI_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json` içinde, +izinler 600, Mac'te ise `~/Library/Application Support/Dikte` altında. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin. @@ -183,8 +186,10 @@ olmasını ister. yapıştırır: cevabı ya da ne yapıldığını söyleyen bir cümle. Kendi açacağın oturumun aynısıdır, yani skill'lerin ve bağlı servislerin oradadır; "bunu perşembe üçe takvime koy" cümlesini Claude olmayan bir pencerede söyleyebilir - olmanı sağlayan da budur. Codex (`codex exec`) da aynı şekilde çalışır; - OpenRouter ise ikisi de kurulu olmayan bir makinede düz soru cevap için + olmanı sağlayan da budur. Codex (`codex exec`) ile Antigravity (`agy -p`) da + aynı şekilde çalışır; ama Antigravity'ye Dikte bir izin kipi ya da sandbox + veremiyor, sormadan ne yapabileceğini kendi allow-rule'ları belirliyor. + OpenRouter ise hiçbiri kurulu olmayan bir makinede düz soru cevap için duruyor. Sağlayıcı, model, izinler ve çalışma dizini Ayarlar → Ajan sekmesinde; arka arkaya verilen komutlar tek bir konuşmada kalır. - **Toplantılar** mikrofonla hoparlör çıkışından aynı anda kaydedilir; kimin ne @@ -232,9 +237,9 @@ cli.py komut satırı: bütün fiiller ve verdikleri cevap 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 +assistant.py dikteyi Claude Code, Codex, agy ya da OpenRouter'dan geçirme api.py transkript ve temizleme istekleri (yalnız stdlib) -cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex +cleanup.py transkripti kim temizler: bulutta bir model, burası, bir CLI 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 update.py yeni sürüm çıkmış mı, çıkmışsa hangi sayfada diff --git a/dikte/api.py b/dikte/api.py index 7a0e78f..16c85c8 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -1,10 +1,17 @@ -"""OpenAI, Groq, OpenRouter and this machine, stdlib only. +"""OpenAI, Groq, OpenRouter, Google AI Studio and this machine, stdlib only. -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. +Transcription runs on the first three and on this machine: 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. + +Google AI Studio is here for cleanup and nothing else. Its OpenAI-compatible +endpoint answers /chat/completions and /models, but there is no +/audio/transcriptions behind it: audio only goes in as base64 inside a chat +message, and what comes back has none of the segment times a subtitle file or a +meeting transcript is built out of. 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. @@ -31,6 +38,7 @@ 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" +GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/openai" # The floor for a local request. The timeouts elsewhere are sized for a hosted # API, where a slow answer is a bill running; here the only thing being spent is @@ -448,14 +456,22 @@ def transcribe_segments(target, audio_path, language="", prompt="", timeout=300, return out +# The settings window offers OpenRouter's ladder, whose top two rungs Google +# has never heard of. Sent as they are the request is refused, which costs the +# cleanup rather than the dictation, so they land on the nearest rung that +# exists instead. +GEMINI_EFFORT = {"xhigh": "high", "max": "high"} + + 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 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 three 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 a Gemini Flash left to + itself thinks too. Cleanup is punctuation rather than a job worth thinking + about. """ if not reasoning: return @@ -463,6 +479,13 @@ def _thinking(payload, provider, reasoning): # 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 provider == "gemini": + # Google's compatibility layer takes OpenAI's flat field rather than + # OpenRouter's object, and "none" is how thinking is turned off there, + # so it is the one level worth sending rather than skipping: a Flash + # model left to think spends exactly the second this provider was + # chosen to save. + payload["reasoning_effort"] = GEMINI_EFFORT.get(reasoning, reasoning) elif reasoning != "none": # The thinking itself is never shown, so ask for it to be left out. payload["reasoning"] = {"effort": reasoning, "exclude": True} @@ -611,6 +634,37 @@ def openrouter_models(api_key="", transcription=False): return sorted(m["id"] for m in models if m.get("id")) +# What a `gemini` id can be besides a model that answers a chat request: an +# embedding, a picture, or a voice. None of them is any use to cleanup. +NOT_CHAT = ("embedding", "-image", "-tts", "-audio") + + +def gemini_models(api_key, base_url=GEMINI_URL): + """The Gemini models Google AI Studio will answer a chat request with. + + Google serves its embedding, image and speech models out of the same list + and names them all `gemini` too, so the prefix alone is not the question; + none of those can clean up a sentence. The listing has also been known to + hand the ids back in their long form, `models/gemini-3.5-flash`, while a + request wants the short one; taking the prefix off costs nothing and is + right whichever form arrives. + """ + service = "Google AI Studio" + if not api_key: + raise ApiError(t("{service} API key is empty. Add it in Settings.", + 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, service) from None + ids = [(m.get("id") or "").removeprefix("models/") for m in data.get("data", [])] + return sorted(i for i in ids + if i.startswith("gemini") and not any(w in i for w in NOT_CHAT)) + + def openai_models(api_key, base_url=OPENAI_URL, service="OpenAI"): """The audio models of anything that speaks OpenAI's /models, Groq included. diff --git a/dikte/assistant.py b/dikte/assistant.py index b57fd06..4abade2 100644 --- a/dikte/assistant.py +++ b/dikte/assistant.py @@ -1,22 +1,28 @@ """Handing a dictation to an agent as a command, and pasting back its answer. -Three of them, because not everyone has the same one installed: +Four of them, because not everyone has the same one installed: Claude Code `claude -p`, the session you would have opened yourself Codex `codex exec`, the same idea from the other shop + Antigravity `agy -p`, Google's, with a browser of its own attached OpenRouter a plain chat request, over the key that is already configured -The first two are the whole machine: they run commands, read files, and reach +The first three are the whole machine: they run commands, read files, and reach whatever skills and services you have connected, which is what makes "put that in my calendar on Thursday" a thing you can say. OpenRouter cannot touch any of -that, and is there so that a question still gets an answer on a machine with -neither CLI installed. +that, and is there so that a question still gets an answer on a machine with no +CLI installed at all. + +What each of the three is allowed to do without asking is settled where that +program keeps its own permissions, not here. Dikte hands Claude Code the mode +chosen in Settings because it has a flag for one; Codex gets a sandbox for the +same reason; Antigravity has neither, and reads its own allow-rules instead. Whichever it is, the reply is pasted exactly where the transcript would have been, and the conversation carries across dictations so that "and move that to Friday" knows what "that" is. -The two CLIs are read as they stream rather than waited out. A command that +The three CLIs are read as they stream rather than waited out. A command that reaches for the calendar or the web takes long enough that a still indicator is indistinguishable from a hang, so every tool they pick up is named in the corner while they work. @@ -34,7 +40,12 @@ from . import config as cfg from .i18n import t SESSION_FILE = cfg.DATA_DIR / "assistant.json" -PROVIDERS = ("claude", "codex", "openrouter") +PROVIDERS = ("claude", "codex", "agy", "openrouter") + +# What each one is called where a person reads it: the tray, the corner of +# the screen, and the line an error is written in. +SERVICES = {"claude": "Claude", "codex": "Codex", "agy": "Antigravity", + "openrouter": "OpenRouter"} # How many messages of an OpenRouter conversation are carried forward. The two # CLIs keep their own history and need no such number; here every turn is resent @@ -66,6 +77,29 @@ CODEX_ITEMS = { "patch_apply": "Editing a file…", "todo_list": "Planning…", } +# Antigravity carries a browser around with it, so the handful of names below +# stand in for the couple of dozen browser_* tools it can pick up; being told +# which mouse button moved is not what the corner of the screen is for. +AGY_TOOLS = { + "run_command": "Running a command…", + "command_status": "Running a command…", + "send_command_input": "Running a command…", + "view_file": "Reading…", + "read_url_content": "Reading a web page…", + "list_dir": "Looking through files…", + "find_by_name": "Looking through files…", + "grep_search": "Searching the files…", + "search_web": "Searching the web…", + "replace_file_content": "Editing a file…", + "multi_replace_file_content": "Editing a file…", + "sed_file": "Editing a file…", + "notebook_edit": "Editing a file…", + "write_to_file": "Writing a file…", + "generate_image": "Drawing…", + "manage_task": "Planning…", + "invoke_subagent": "Handing it to a subagent…", + "browser_subagent": "Handing it to a subagent…", +} # How hard to think, in each provider's own vocabulary. The setting is one @@ -81,6 +115,12 @@ CLAUDE_EFFORT = {"none": "low", "minimal": "low", "low": "low", CODEX_EFFORT = {"none": "low", "minimal": "low", "low": "low", "medium": "medium", "high": "high", "xhigh": "high", "max": "high"} +# agy has three rungs and no word for off, so the bottom of the ladder lands on +# "low" and the top two on "high". Shared with cleanup, which runs the same +# program for the smaller job. +AGY_EFFORT = {"none": "low", "minimal": "low", "low": "low", + "medium": "medium", "high": "high", "xhigh": "high", + "max": "high"} class AssistantError(Exception): @@ -98,12 +138,28 @@ def provider(conf): def executable(name): """The CLI a provider runs, or "" when it needs none.""" - return {"claude": "claude", "codex": "codex"}.get(name, "") + return {"claude": "claude", "codex": "codex", "agy": "agy"}.get(name, "") + + +def model(conf): + """Which model answered, for the history to record. + + Each provider keeps its own setting, and the one a CLI is left on has no id + to report, only a name — the same arrangement cleanup.model() makes. + """ + name = provider(conf) + if name == "codex": + return conf["assistant_codex_model"].strip() or "codex" + if name == "agy": + return conf["assistant_agy_model"].strip() or "agy" + if name == "openrouter": + return conf["assistant_openrouter_model"] + return conf["assistant_model"] def display_name(conf): """What to call the thing being asked, in the tray and in the corner.""" - return {"claude": "Claude", "codex": "Codex"}.get(provider(conf), "OpenRouter") + return SERVICES.get(provider(conf), "OpenRouter") # --- the conversation ----------------------------------------------------- @@ -206,7 +262,7 @@ def ask(prompt, conf, on_stage=None, should_stop=None): "Settings → Agent.", binary=binary, )) - run = _ask_claude if name == "claude" else _ask_codex + run = {"claude": _ask_claude, "codex": _ask_codex, "agy": _ask_agy}[name] session = read_session(name, conf["assistant_session_minutes"] * 60) try: return run(prompt, conf, session, on_stage, should_stop) @@ -258,7 +314,7 @@ def _ask_claude(prompt, conf, session, on_stage, should_stop): found["warning"] = _denial_warning(event) code, stderr = _stream(cmd, conf, on_event, should_stop) - return _conclude(found, code, stderr, session, "Claude") + return _conclude(found, code, stderr, session, "claude") def _claude_label(block): @@ -326,7 +382,7 @@ def _ask_codex(prompt, conf, session, on_stage, should_stop): else str(error)) or t("Codex ended with an error.") code, stderr = _stream(cmd, conf, on_event, should_stop) - return _conclude(found, code, stderr, session, "Codex") + return _conclude(found, code, stderr, session, "codex") def _codex_label(item): @@ -338,6 +394,71 @@ def _codex_label(item): return t("Using {name}…", name=item_type or "a tool") +# --- Antigravity ---------------------------------------------------------- + +def _ask_agy(prompt, conf, session, on_stage, should_stop): + # Antigravity takes no system prompt of its own either, so the instruction + # rides in front of the command, kept apart from it so the two are not read + # as one. + body = f"{conf.assistant_prompt()}\n\n---\n\n{prompt}" + cmd = [ + "agy", "-p", body, + "--output-format", "stream-json", + # agy stops after five minutes unless it is told otherwise, which is + # shorter than the timeout this setting offers. + "--print-timeout", f"{conf['assistant_timeout']}s", + ] + # One or the other, always: left with neither, agy picks up whichever + # project it was last in and works in that project's directory rather than + # the one _stream is about to start it in. + cmd += ["--conversation", session] if session else ["--new-project"] + if conf["assistant_agy_model"].strip(): + cmd += ["--model", conf["assistant_agy_model"].strip()] + effort = AGY_EFFORT.get(conf["assistant_reasoning"], "") + if effort: + # Most of agy's own model ids carry the effort in their suffix already; + # this is for the ones that do not. + cmd += ["--effort", effort] + + found = {"answer": "", "warning": "", "session": "", "failure": ""} + + def on_event(event): + kind = event.get("event") + if kind == "init": + found["session"] = event.get("conversation_id") or found["session"] + elif kind == "step_update": + step = event.get("step_update") or {} + # A tool is reported twice, once when it starts and once when it is + # done; the corner wants the first of those. + if (on_stage and step.get("step_type") == "tool" + and step.get("state") == "ACTIVE"): + on_stage(_agy_label(step)) + elif kind == "result": + result = event.get("result") or {} + found["session"] = result.get("conversation_id") or found["session"] + answer = (result.get("response") or "").strip() + if result.get("status") == "SUCCESS": + found["answer"] = answer + else: + found["failure"] = answer or t("{service} ended with an error.", + service="Antigravity") + + code, stderr = _stream(cmd, conf, on_event, should_stop) + return _conclude(found, code, stderr, session, "agy") + + +def _agy_label(step): + name = step.get("tool_name", "") + if name in AGY_TOOLS: + return t(AGY_TOOLS[name]) + if name.startswith("browser_") or name.startswith("capture_browser"): + return t("Working in the browser…") + if name == "call_mcp_tool": + server = (step.get("tool_info") or {}).get("parameters") or {} + return t("Using {name}…", name=server.get("server") or "a tool") + return t("Using {name}…", name=name or "a tool") + + # --- OpenRouter ----------------------------------------------------------- def _ask_openrouter(prompt, conf, on_stage): @@ -420,8 +541,9 @@ def _stream(cmd, conf, on_event, should_stop): return proc.returncode, stderr -def _conclude(found, code, stderr, session, service): +def _conclude(found, code, stderr, session, name): """Turn what the stream said into an answer, or into the reason there is none.""" + service = SERVICES.get(name, name) if code != 0 and not found["answer"]: if session and _session_missing(stderr): raise _SessionGone() @@ -432,7 +554,7 @@ def _conclude(found, code, stderr, session, service): if not found["answer"]: raise AssistantError(t("{service} answered with nothing.", service=service)) if found["session"]: - write_session("claude" if service == "Claude" else "codex", found["session"]) + write_session(name, found["session"]) return found["answer"], found["warning"] diff --git a/dikte/cleanup.py b/dikte/cleanup.py index 96eab1d..8c4a1fd 100644 --- a/dikte/cleanup.py +++ b/dikte/cleanup.py @@ -1,7 +1,8 @@ """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 +Normally a small model over one HTTP request: a second, and a few tenths of a +cent on OpenRouter or nothing at all on Google AI Studio's free tier. A machine +with Claude Code, Codex or Antigravity 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 @@ -10,7 +11,11 @@ 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. +the agent can reach while it reads one, the better. Claude Code is handed an +empty tool list and Codex a read-only sandbox. Antigravity has neither switch, +and this is worth saying plainly rather than implying parity: there the +transcript is read by an agent that could go and do something. What can be done +is done — a project of its own, the home directory, and its slash commands off. """ import os @@ -23,7 +28,7 @@ from . import assistant from . import ggml from .i18n import t -PROVIDERS = ("openrouter", "local", "claude", "codex") +PROVIDERS = ("openrouter", "gemini", "local", "claude", "codex", "agy") class CleanupError(api.ApiError): @@ -42,7 +47,7 @@ def provider(conf): def executable(name): """The CLI a provider runs, or "" when it needs none.""" - return {"claude": "claude", "codex": "codex"}.get(name, "") + return {"claude": "claude", "codex": "codex", "agy": "agy"}.get(name, "") def model(conf): @@ -56,13 +61,18 @@ def model(conf): # 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" + if name == "agy": + # The same arrangement as Codex, and the same reason for it. + return conf["cleanup_agy_model"].strip() or "agy" + if name == "gemini": + return conf["cleanup_gemini_model"] return conf["cleanup_model"] def run(text, conf, system_prompt, timeout=180, aborter=None): """Hand the transcript to whoever is set to clean it up. - `aborter` is only of use to the two that answer over HTTP; a CLI is stopped + `aborter` is only of use to the three that answer over HTTP; a CLI is stopped between blocks instead, which is close enough when a block is seconds. """ name = provider(conf) @@ -73,9 +83,16 @@ def run(text, conf, system_prompt, timeout=180, aborter=None): base_url=conf["openrouter_base_url"], timeout=timeout, aborter=aborter, ) + if name == "gemini": + return api.cleanup( + text, conf.gemini_key(), conf["cleanup_gemini_model"], system_prompt, + reasoning=conf["cleanup_reasoning"], + base_url=conf["gemini_base_url"], timeout=timeout, + provider="gemini", service="Google AI Studio", aborter=aborter, + ) if name == "local": return _local(text, conf, system_prompt, timeout, aborter) - runner = _claude if name == "claude" else _codex + runner = {"claude": _claude, "codex": _codex, "agy": _agy}[name] return runner(text, conf, system_prompt, timeout) @@ -171,6 +188,40 @@ def _codex(text, conf, system_prompt, timeout): return answer +# --- Antigravity ---------------------------------------------------------- + +def _agy(text, conf, system_prompt, timeout): + # Antigravity takes no system prompt of its own either, 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 = [ + "agy", "-p", body, + "--output-format", "text", # the answer, and nothing around it + # Left to itself agy picks up whichever project it was last in and works + # in that project's directory rather than this one. A dictation belongs + # to no project, so each one starts on a project of its own. + "--new-project", + # A transcript that happens to begin with a slash is still a transcript. + "--disable-slash-commands", + # agy gives up after five minutes of its own accord, which would have it + # killed from outside rather than answering. + "--print-timeout", f"{timeout}s", + ] + if conf["cleanup_agy_model"].strip(): + cmd += ["--model", conf["cleanup_agy_model"].strip()] + effort = assistant.AGY_EFFORT.get(conf["cleanup_reasoning"], "") + if effort: + # agy's own model ids carry the effort in their suffix, so this only + # matters for the ones that do not, and for a model typed in by hand. + cmd += ["--effort", effort] + + answer = _output(cmd, timeout, "Antigravity") + if not answer: + raise CleanupError(t("{service} answered with nothing.", + service="Antigravity")) + return answer + + def _read(path): try: with open(path, encoding="utf-8", errors="replace") as fh: diff --git a/dikte/cli.py b/dikte/cli.py index 3109348..2188242 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -227,7 +227,8 @@ def cmd_ask(opts): conf["assistant_provider"] = opts.provider if opts.model: key = {"claude": "assistant_model", "codex": "assistant_codex_model", - "openrouter": "assistant_openrouter_model"}[assistant.provider(conf)] + "openrouter": "assistant_openrouter_model", + "agy": "assistant_agy_model"}[assistant.provider(conf)] conf[key] = opts.model if opts.dir: conf["assistant_dir"] = opts.dir @@ -266,7 +267,8 @@ def cmd_ask(opts): "cleanup_error": warning, "mode": "ask", "question": text, - "assistant_model": conf["assistant_model"], + "assistant": assistant.provider(conf), + "assistant_model": assistant.model(conf), "raw": text, "text": answer, }) @@ -519,7 +521,7 @@ def cmd_history_clear(opts): # --- settings --------------------------------------------------------------- -SECRET_KEYS = ("openai_api_key", "openrouter_api_key") +SECRET_KEYS = ("openai_api_key", "openrouter_api_key", "gemini_api_key") def _mask(key, value): @@ -709,6 +711,14 @@ def cmd_test_key(opts): results[name] = {"ok": True, "message": message} except api.ApiError as exc: results[name] = {"ok": False, "message": str(exc)} + if opts.which in ("gemini", "all"): + try: + count = len(api.gemini_models(conf.gemini_key(), + conf["gemini_base_url"])) + message = f"connection works, {count} models visible" + results["gemini"] = {"ok": True, "message": message} + except api.ApiError as exc: + results["gemini"] = {"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()] @@ -869,13 +879,22 @@ def cmd_doctor(opts): programs = {name: shutil.which(name) or "" for name in wanted if name} target = conf.transcribe_target() cleaner = cleanup.provider(conf) + cleanup_binary = cleanup.executable(cleaner) + # Only the two that answer over HTTP have a key worth looking at. A CLI has + # a program to find instead, and the model on this machine has neither, so + # "no key" there has to read as beside the point rather than as one that has + # gone missing. + cleanup_service, cleanup_key = { + "openrouter": ("OpenRouter", conf.openrouter_key()), + "gemini": ("Google AI Studio", conf.gemini_key()), + }.get(cleaner, ("", "")) checks = { "programs": programs, "transcription": {"provider": target.provider, "model": target.model, "key": bool(target.api_key)}, "cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner, "model": cleanup.model(conf), - "key": bool(conf.openrouter_key())}, + "key": bool(cleanup_key) if cleanup_service else None}, "agent": {"provider": assistant.provider(conf), "directory": assistant.working_dir(conf)}, "running": ipc.send("status") is not None, @@ -885,11 +904,13 @@ def cmd_doctor(opts): lines += [ f"{'✓' if target.api_key else '✗'} {target.service} key, transcribing on " f"{target.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)}"), + # Cleanup on a CLI needs no key, so what is checked is the program; + # cleanup on this machine has neither, and the model is the whole answer. + (f"{'✓' if cleanup_key else '✗'} {cleanup_service} key, cleaning up on " + f"{cleanup.model(conf)}") if cleanup_service else + (f"{'✓' if programs[cleanup_binary] else '✗'} {cleanup_binary}, " + f"cleaning up on {cleanup.model(conf)}") if cleanup_binary else + f"· cleaning up here, on {cleanup.model(conf)}", f"{'✓' if checks['running'] else '·'} application " + ("running" if checks["running"] else "not running"), ] @@ -977,7 +998,7 @@ def build_parser(): ask = leaf(subs, "ask", "put a command to the agent") ask.add_argument("text", nargs="*", help="the command; read from stdin, or " "recorded when there is none") - ask.add_argument("--provider", choices=("claude", "codex", "openrouter"), + ask.add_argument("--provider", choices=assistant.PROVIDERS, help="just for this run") ask.add_argument("--model", help="just for this run") ask.add_argument("--dir", help="working directory, just for this run") @@ -1100,7 +1121,7 @@ def build_parser(): models.set_defaults(func=cmd_models) test = leaf(subs, "test-key", "check the API keys") test.add_argument("which", nargs="?", default="all", - choices=("all", *cfg.TRANSCRIBERS)) + choices=("all", *cfg.TRANSCRIBERS, "gemini")) test.set_defaults(func=cmd_test_key) leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor) diff --git a/dikte/config.py b/dikte/config.py index fb7683a..1bff671 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -385,6 +385,10 @@ DEFAULTS = { "groq_base_url": "https://api.groq.com/openai/v1", "openrouter_api_key": "", "openrouter_base_url": "https://openrouter.ai/api/v1", + "gemini_api_key": "", + # Google's OpenAI-compatible endpoint. Cleanup only: there is no + # /audio/transcriptions behind it, so it is not one of the TRANSCRIBERS. + "gemini_base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "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", @@ -410,6 +414,8 @@ DEFAULTS = { "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_gemini_model": "gemini-3.5-flash-lite", + "cleanup_agy_model": "", # empty -> whatever Antigravity is set to "cleanup_reasoning": "", # empty -> whatever the model does by default # --- llama.cpp, on this machine ----------------------------------------- @@ -482,12 +488,13 @@ DEFAULTS = { # --- speaking a command to an agent ------------------------------------- "assistant_shortcut": "", # empty -> tray only - "assistant_provider": "claude", # claude | codex | openrouter + "assistant_provider": "claude", # claude | codex | agy | openrouter "assistant_model": "sonnet", # Claude Code: an alias, or a full model id "assistant_permission_mode": "auto", "assistant_codex_model": "", # empty -> whatever Codex is set to "assistant_codex_sandbox": "workspace-write", "assistant_openrouter_model": "google/gemini-3.5-flash", + "assistant_agy_model": "", # empty -> whatever Antigravity is set to "assistant_reasoning": "", # empty -> the model's own default "assistant_dir": "", # empty -> the home directory "assistant_prompt": "", # empty -> language-specific default @@ -589,6 +596,9 @@ class Config: def openrouter_key(self): return self.api_key("openrouter_api_key") + def gemini_key(self): + return self.api_key("gemini_api_key") + def transcribe_target(self): """Key, endpoint and model for whichever provider does speech to text. diff --git a/dikte/i18n.py b/dikte/i18n.py index f7f4910..a3e3c05 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -40,8 +40,14 @@ def t(text, /, **kwargs): # by the sentence, so it arrives already inflected. English takes the name as it # is and puts the preposition in the sentence, where it belongs. _TR_CASES = { - "dative": {"Claude": "Claude'a", "Codex": "Codex'e", "OpenRouter": "OpenRouter'a"}, - "accusative": {"Claude": "Claude'u", "Codex": "Codex'i", "OpenRouter": "OpenRouter'ı"}, + "dative": { + "Claude": "Claude'a", "Codex": "Codex'e", "OpenRouter": "OpenRouter'a", + "Google AI Studio": "Google AI Studio'ya", "Antigravity": "Antigravity'ye", + }, + "accusative": { + "Claude": "Claude'u", "Codex": "Codex'i", "OpenRouter": "OpenRouter'ı", + "Google AI Studio": "Google AI Studio'yu", "Antigravity": "Antigravity'yi", + }, } @@ -221,26 +227,33 @@ TR = { "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)", + "(falls back to GEMINI_API_KEY)": "(boşsa GEMINI_API_KEY kullanılır)", "Test": "Test et", "Trying…": "Deneniyor…", "Runs on OpenRouter.": "OpenRouter üzerinde çalışır.", + "Runs on Google AI Studio.": "Google AI Studio üzerinde çalışır.", "Connection works. {count} audio models visible.": "Bağlantı tamam. {count} ses modeli görünüyor.", + "Connection works. {count} models visible.": + "Bağlantı tamam. {count} model 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.", + "OpenRouter and Google AI Studio are the quick ones that need nothing " + "installed. llama.cpp runs here, on a model downloaded below. Claude Code, " + "Codex and Antigravity clean up on a subscription you already have, without " + "a second key, and take a few seconds longer because each opens a session " + "to do it.": + "OpenRouter ile Google AI Studio kurulum istemeyen hızlı seçeneklerdir. " + "llama.cpp burada, aşağıdan indirilen bir modelle çalışır. Claude Code, " + "Codex ve Antigravity temizliği hâlihazırda sahip olduğun bir abonelik " + "üzerinden, ikinci anahtar olmadan yapar; 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ı", + "Antigravity's own default": "Antigravity'nin kendi varsayılanı", "Off": "Kapalı", "Minimal": "En az", "Low": "Düşük", @@ -506,15 +519,15 @@ TR = { "This shortcut records the same way dictation does, but the transcript is " "not what gets pasted. It goes to an agent as a command, and what comes " "back is pasted instead: the answer to a question, or a sentence saying " - "what was done. Claude Code and Codex run as the session you would have " - "opened yourself, with your skills, your connected services and your " - "account.": + "what was done. Claude Code, Codex and Antigravity run as the session you " + "would have opened yourself, with your skills, your connected services and " + "your account.": "Bu kısayol dikte ile aynı şekilde kaydeder, ama yapıştırılan şey " "transkript değildir. Transkript bir ajana komut olarak gider ve yerine " "oradan döneni yapıştırılır: bir sorunun cevabı ya da ne yapıldığını " - "söyleyen bir cümle. Claude Code ve Codex, kendi açacağın oturumun " - "aynısı olarak çalışır: skill'lerinle, bağlı servislerinle ve kendi " - "hesabınla.", + "söyleyen bir cümle. Claude Code, Codex ve Antigravity kendi açacağın " + "oturumun aynısı olarak çalışır: skill'lerinle, bağlı servislerinle " + "ve kendi hesabınla.", "How it runs": "Nasıl çalışıyor", "Runs on": "Şunun üstünde çalışır", "More thinking is slower, and you are standing in front of the screen while " @@ -541,6 +554,13 @@ TR = { "Yukarıdaki çalışma dizini ve izinler burada bir şey ifade etmez.", "Needs no program installed, only the OpenRouter key.": "Kurulu bir programa değil, yalnızca OpenRouter anahtarına ihtiyaç duyar.", + "Antigravity has neither a permission mode nor a sandbox to hand it, so " + "what it may do without asking is whatever its own allow-rules say. The " + "Permissions and Sandbox boxes above belong to the other two; the working " + "directory still applies.": + "Antigravity'ye verilebilecek bir izin kipi ya da sandbox yok; sormadan " + "ne yapabileceğini kendi allow-rule'ları belirler. Yukarıdaki İzinler ve " + "Sandbox kutuları diğer ikisine ait; çalışma dizini burada da geçerli.", "{binary} is not on your PATH, so this cannot run yet. Install it, or pick " "another one above.": "{binary} PATH'te değil, dolayısıyla bu henüz çalışamaz. Kur ya da " @@ -595,7 +615,7 @@ TR = { "configuration already says.": "Her komutla birlikte ajana söylenir, kendi yapılandırmanın zaten " "söylediklerinin üstüne eklenir.", - " · asked Claude: {question}": " · Claude'a soruldu: {question}", + " · asked {who}: {question}": " · {who} soruldu: {question}", # --- meetings: tray and pipeline --------------------------------------- "Record a meeting": "Toplantı kaydet", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index d3a97b7..816ade0 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -28,6 +28,7 @@ from . import meeting from . import paste from . import update from .filetranscribe import FileTranscriber +from . import i18n from .i18n import t UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")] @@ -57,12 +58,24 @@ 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. +GEMINI_MODELS = [ + "gemini-3.5-flash-lite", "gemini-3.1-flash-lite", + "gemini-2.5-flash-lite", "gemini-3.5-flash", "gemini-2.5-flash", +] +# agy's model ids carry the reasoning effort in their suffix, which is why one +# model appears here at more than one level. The same list seeds two boxes: +# cleanup, which wants the bottom rung, and the agent, which sometimes does not. +AGY_MODELS = [ + "gemini-3.7-flash-low", "gemini-3.7-flash-medium", "gemini-3.7-flash-high", + "gemini-3.5-flash-low", "gemini-3.1-pro-low", +] +# In the order they answer in. The hosted requests are over in a second, a +# model here takes a little longer and costs nothing, and the three 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"), + ("OpenRouter", "openrouter"), ("Google AI Studio", "gemini"), + ("This machine (llama.cpp)", "local"), ("Claude Code", "claude"), + ("Codex", "codex"), ("Antigravity", "agy"), ] # Cleaning up a sentence is the lightest thing either of them will ever be # asked, so the small model comes first. @@ -74,7 +87,8 @@ MEETING_MODELS = [ "anthropic/claude-sonnet-5", "openai/gpt-5.4", "x-ai/grok-4.5", ] ASSISTANT_PROVIDERS = [ - ("Claude Code", "claude"), ("Codex", "codex"), ("OpenRouter", "openrouter"), + ("Claude Code", "claude"), ("Codex", "codex"), ("Antigravity", "agy"), + ("OpenRouter", "openrouter"), ] # Aliases resolve to the newest model of that name, so they age better than an # id does; a full id can be typed in when a particular one is wanted. @@ -520,6 +534,7 @@ class SettingsWindow(QDialog): update_found = pyqtSignal(object) _models_loaded = pyqtSignal(list, str) + _gemini_models_loaded = pyqtSignal(list, str) _transcribe_models_loaded = pyqtSignal(list, str) # Which key was tested, whether it worked, and what to write under it. _test_done = pyqtSignal(str, bool, str) @@ -576,6 +591,7 @@ class SettingsWindow(QDialog): self._size_to_screen(680, 640) self._models_loaded.connect(self._on_models_loaded) + self._gemini_models_loaded.connect(self._on_gemini_models_loaded) self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded) self._test_done.connect(self._on_test_done) self._update_checked.connect(self._on_update_checked) @@ -746,6 +762,9 @@ class SettingsWindow(QDialog): self.openrouter_key = self._key_row( keys_form, "openrouter", t("sk-or-… (falls back to OPENROUTER_API_KEY)"), self._test_openrouter) + self.gemini_key = self._key_row( + keys_form, "gemini", t("(falls back to GEMINI_API_KEY)"), + self._test_gemini, service="Google AI Studio") outer.addWidget(keys) stt = QGroupBox(t("Speech to text")) @@ -818,11 +837,11 @@ class SettingsWindow(QDialog): 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 " + "OpenRouter and Google AI Studio are the quick ones that need 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." + "Code, Codex and Antigravity clean up on a subscription you already " + "have, without a second key, and take a few seconds longer because " + "each opens a session to do it." )) self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed) orr_form.addRow(t("Runs on"), self.cleanup_provider) @@ -835,6 +854,15 @@ class SettingsWindow(QDialog): self.cleanup_model_row = self._row(self.cleanup_model, self.refresh_models) orr_form.addRow(t("Model"), self.cleanup_model_row) + self.cleanup_gemini_model = QComboBox() + self.cleanup_gemini_model.setEditable(True) + self.cleanup_gemini_model.addItems(GEMINI_MODELS) + self.refresh_gemini_models = QPushButton(t("Fetch model list")) + self.refresh_gemini_models.clicked.connect(self._load_gemini_models) + self.cleanup_gemini_model_row = self._row( + self.cleanup_gemini_model, self.refresh_gemini_models) + orr_form.addRow(t("Model"), self.cleanup_gemini_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. @@ -848,6 +876,11 @@ class SettingsWindow(QDialog): self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS) orr_form.addRow(t("Model"), self.cleanup_codex_model) + self.cleanup_agy_model = QComboBox() + self.cleanup_agy_model.setEditable(True) + self.cleanup_agy_model.addItems([t("Antigravity's own default")] + AGY_MODELS) + orr_form.addRow(t("Model"), self.cleanup_agy_model) + self.cleanup_reasoning = QComboBox() for label, value in REASONING_LEVELS: self.cleanup_reasoning.addItem(t(label), value) @@ -931,9 +964,9 @@ class SettingsWindow(QDialog): "This shortcut records the same way dictation does, but the " "transcript is not what gets pasted. It goes to an agent as a " "command, and what comes back is pasted instead: the answer to a " - "question, or a sentence saying what was done. Claude Code and " - "Codex run as the session you would have opened yourself, with your " - "skills, your connected services and your account." + "question, or a sentence saying what was done. Claude Code, Codex " + "and Antigravity run as the session you would have opened yourself, " + "with your skills, your connected services and your account." )) intro.setWordWrap(True) layout.addWidget(intro) @@ -971,7 +1004,7 @@ class SettingsWindow(QDialog): dir_note.setWordWrap(True) how_form.addRow(dir_note) - # One scale for all three: how hard to think is one thing to want, and + # One scale for all four: how hard to think is one thing to want, and # each provider is handed the nearest rung it actually has. self.assistant_reasoning = QComboBox() for label, value in REASONING_LEVELS: @@ -994,7 +1027,7 @@ class SettingsWindow(QDialog): layout.addWidget(how) # One box per provider, only the chosen one on screen: they have nothing - # in common past the model, and three sets of half-relevant fields would + # in common past the model, and four sets of half-relevant fields would # be worse than none. self.claude_box = QGroupBox(t("Claude Code")) claude_form = QFormLayout(self.claude_box) @@ -1044,6 +1077,24 @@ class SettingsWindow(QDialog): or_form.addRow(or_note) layout.addWidget(self.openrouter_box) + self.agy_box = QGroupBox(t("Antigravity")) + agy_form = QFormLayout(self.agy_box) + self.assistant_agy_model = QComboBox() + self.assistant_agy_model.setEditable(True) + self.assistant_agy_model.addItem(t("Antigravity's own default"), "") + for name in AGY_MODELS: + self.assistant_agy_model.addItem(name, name) + agy_form.addRow(t("Model"), self.assistant_agy_model) + agy_note = QLabel(t( + "Antigravity has neither a permission mode nor a sandbox to hand it, " + "so what it may do without asking is whatever its own allow-rules " + "say. The Permissions and Sandbox boxes above belong to the other " + "two; the working directory still applies." + )) + agy_note.setWordWrap(True) + agy_form.addRow(agy_note) + layout.addWidget(self.agy_box) + thread = QGroupBox(t("The conversation")) thread_form = QFormLayout(thread) self.assistant_session_minutes = QSpinBox() @@ -1515,7 +1566,7 @@ class SettingsWindow(QDialog): box.lineEdit().setPlaceholderText(placeholder) return box - def _key_row(self, form, provider, placeholder, tester): + def _key_row(self, form, provider, placeholder, tester, service=""): """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 @@ -1529,7 +1580,8 @@ class SettingsWindow(QDialog): button.clicked.connect(tester) answer = QLabel("") answer.setWordWrap(True) - form.addRow(cfg.TRANSCRIBERS[provider].service, self._row(field, button)) + label = service or cfg.TRANSCRIBERS[provider].service + form.addRow(label, self._row(field, button)) form.addRow("", answer) self._key_fields[provider] = field self._testers[provider] = (button, answer) @@ -1603,6 +1655,7 @@ class SettingsWindow(QDialog): for name, who in cfg.TRANSCRIBERS.items(): self._key_fields[name].setText(conf[who.key]) self._models[name] = conf[who.model] + self.gemini_key.setText(conf["gemini_api_key"]) self._shown_provider = "" self._select_data(self.transcribe_provider, conf["transcribe_provider"]) self._provider_changed() # selecting index 0 fires no signal @@ -1613,10 +1666,16 @@ class SettingsWindow(QDialog): self.cleanup_enabled.setChecked(conf["cleanup_enabled"]) self.cleanup_model.setCurrentText(conf["cleanup_model"]) + self.cleanup_gemini_model.setCurrentText( + conf["cleanup_gemini_model"] or cfg.DEFAULTS["cleanup_gemini_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.cleanup_agy_model.setCurrentText( + conf["cleanup_agy_model"] or t("Antigravity'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"]) @@ -1636,6 +1695,7 @@ class SettingsWindow(QDialog): self.assistant_codex_model.setCurrentText(conf["assistant_codex_model"]) self._select_data(self.assistant_codex_sandbox, conf["assistant_codex_sandbox"]) self.assistant_openrouter_model.setCurrentText(conf["assistant_openrouter_model"]) + self.assistant_agy_model.setCurrentText(conf["assistant_agy_model"]) self._assistant_provider_changed() # selecting index 0 fires no signal self._select_data(self.assistant_reasoning, conf["assistant_reasoning"]) self.assistant_dir.setText(conf["assistant_dir"]) @@ -1701,6 +1761,7 @@ class SettingsWindow(QDialog): 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["gemini_api_key"] = self.gemini_key.text().strip() conf["local_model"] = self.local_whisper.selected() conf["local_gpu"] = self.local_gpu.isChecked() conf["local_preload"] = self.local_preload.isChecked() @@ -1709,12 +1770,21 @@ 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_gemini_model"] = ( + self.cleanup_gemini_model.currentText().strip() + or cfg.DEFAULTS["cleanup_gemini_model"] + ) 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 ) + agy_cleanup_model = self.cleanup_agy_model.currentText().strip() + conf["cleanup_agy_model"] = ( + "" if agy_cleanup_model == t("Antigravity's own default") + else agy_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() @@ -1748,6 +1818,10 @@ class SettingsWindow(QDialog): self.assistant_openrouter_model.currentText().strip() or cfg.DEFAULTS["assistant_openrouter_model"] ) + agy_model = self.assistant_agy_model.currentText().strip() + conf["assistant_agy_model"] = ( + "" if agy_model == t("Antigravity's own default") else agy_model + ) conf["assistant_reasoning"] = self.assistant_reasoning.currentData() or "" conf["assistant_dir"] = self.assistant_dir.text().strip() conf["assistant_timeout"] = self.assistant_timeout.value() @@ -1876,6 +1950,30 @@ class SettingsWindow(QDialog): combo.setCurrentText(current) self.models_label.setText(t("{count} models loaded.", count=len(models))) + def _load_gemini_models(self): + self.refresh_gemini_models.setEnabled(False) + self.models_label.setText(t("Fetching model list…")) + key, base = self._typed_key("gemini") + + def work(): + try: + self._gemini_models_loaded.emit(api.gemini_models(key, base), "") + except api.ApiError as exc: + self._gemini_models_loaded.emit([], str(exc)) + + threading.Thread(target=work, daemon=True).start() + + def _on_gemini_models_loaded(self, models, error): + self.refresh_gemini_models.setEnabled(True) + if error: + self.models_label.setText(t("Could not fetch the list: {error}", error=error)) + return + current = self.cleanup_gemini_model.currentText() + self.cleanup_gemini_model.clear() + self.cleanup_gemini_model.addItems(models) + self.cleanup_gemini_model.setCurrentText(current) + self.models_label.setText(t("{count} models loaded.", count=len(models))) + def _test_openai(self): key, base = self._typed_key("openai") self._test_key("openai", lambda: t( @@ -1894,11 +1992,23 @@ class SettingsWindow(QDialog): key, _ = self._typed_key("openrouter") self._test_key("openrouter", lambda: api.openrouter_key_status(key)) + def _test_gemini(self): + key, base = self._typed_key("gemini") + self._test_key("gemini", lambda: t( + "Connection works. {count} models visible.", + count=len(api.gemini_models(key, base)), + )) + def _typed_key(self, provider): """(key, base URL) for a provider, preferring what is in the field now.""" - who = cfg.TRANSCRIBERS[provider] + if provider in cfg.TRANSCRIBERS: + who = cfg.TRANSCRIBERS[provider] + key_setting, url_setting = who.key, who.url + else: + key_setting = f"{provider}_api_key" + url_setting = f"{provider}_base_url" typed = self._key_fields[provider].text().strip() - return typed or self.conf.api_key(who.key), self.conf[who.url] + return typed or self.conf.api_key(key_setting), self.conf[url_setting] def _test_key(self, provider, ask): """Run `ask` off the interface thread and write its answer under the key. @@ -2114,10 +2224,14 @@ class SettingsWindow(QDialog): provider = self.cleanup_provider.currentData() or "openrouter" self.cleanup_form.setRowVisible(self.cleanup_model_row, provider == "openrouter") + self.cleanup_form.setRowVisible(self.cleanup_gemini_model_row, + provider == "gemini") 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_agy_model, + provider == "agy") self.cleanup_form.setRowVisible(self.cleanup_reasoning, provider != "local") self.cleanup_form.setRowVisible(self.local_llm, provider == "local") @@ -2126,6 +2240,8 @@ class SettingsWindow(QDialog): found = shutil.which(binary) if binary else "" if provider == "local": self.models_label.setText(t("Runs on this machine, on llama.cpp.")) + elif provider == "gemini": + self.models_label.setText(t("Runs on Google AI Studio.")) elif not binary: self.models_label.setText(t("Runs on OpenRouter.")) elif found: @@ -2142,6 +2258,7 @@ class SettingsWindow(QDialog): self.claude_box.setVisible(provider == "claude") self.codex_box.setVisible(provider == "codex") self.openrouter_box.setVisible(provider == "openrouter") + self.agy_box.setVisible(provider == "agy") self._refresh_assistant_status() def _refresh_assistant_status(self): @@ -2270,7 +2387,11 @@ class SettingsWindow(QDialog): # The text of an answer says nothing about what was asked, and # out of that context half of them read like non sequiturs. asked = (row.get("question") or row.get("raw") or "").replace("\n", " ") - header += t(" · asked Claude: {question}", + # Rows written before the provider was recorded are all Claude's, + # because it was the only one the history could name. + who = assistant.SERVICES.get(row.get("assistant"), "Claude") + header += t(" · asked {who}: {question}", + who=i18n.name(who, "dative"), question=asked[:60] + ("…" if len(asked) > 60 else "")) item = QListWidgetItem(f"{header}\n{preview}") item.setData(Qt.ItemDataRole.UserRole, row) diff --git a/dikte/worker.py b/dikte/worker.py index 16cdbad..7013c66 100644 --- a/dikte/worker.py +++ b/dikte/worker.py @@ -162,7 +162,8 @@ class Pipeline(QObject): "cleanup_error": warning, "mode": "ask" if ask else "", "question": question, - "assistant_model": conf["assistant_model"] if ask else "", + "assistant": assistant.provider(conf) if ask else "", + "assistant_model": assistant.model(conf) if ask else "", "raw": raw, "text": text, }) diff --git a/tests/test_api.py b/tests/test_api.py index 6bb28f8..09b49e1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -384,6 +384,37 @@ class Cleanup(DikteTest): self.assertEqual(sent_json(calls[0])["reasoning"], {"effort": "high", "exclude": True}) + def test_gemini_takes_openai_s_flat_field_rather_than_the_object(self): + _, calls = self.call(chat_reply("Hello."), reasoning="low", + provider="gemini", service="Google AI Studio") + payload = sent_json(calls[0]) + self.assertEqual(payload["reasoning_effort"], "low") + self.assertNotIn("reasoning", payload) + + def test_gemini_is_told_to_stop_thinking_rather_than_left_alone(self): + """"none" is the one level worth sending: Flash thinks by default.""" + _, calls = self.call(chat_reply("Hello."), reasoning="none", + provider="gemini", service="Google AI Studio") + self.assertEqual(sent_json(calls[0])["reasoning_effort"], "none") + + def test_a_rung_google_does_not_have_lands_on_the_nearest_one(self): + for asked in ("xhigh", "max"): + with self.subTest(asked=asked): + _, calls = self.call(chat_reply("Hello."), reasoning=asked, + provider="gemini", service="Google AI Studio") + self.assertEqual(sent_json(calls[0])["reasoning_effort"], "high") + + def test_gemini_left_on_the_model_s_own_default_is_told_nothing(self): + _, calls = self.call(chat_reply("Hello."), provider="gemini", + service="Google AI Studio") + self.assertNotIn("reasoning_effort", sent_json(calls[0])) + + def test_a_missing_gemini_key_says_google_ai_studio(self): + with self.assertRaises(api.ApiError) as caught: + api.cleanup("hello", "", "gemini-3.5-flash-lite", "prompt", + provider="gemini", service="Google AI Studio") + self.assertIn("Google AI Studio", str(caught.exception)) + def test_a_local_base_url(self): _, calls = self.call(chat_reply("Hello."), base_url="http://localhost:1234/v1") self.assertEqual(calls[0].full_url, "http://localhost:1234/v1/chat/completions") @@ -513,6 +544,40 @@ class ModelLists(DikteTest): api.openai_models("", api.GROQ_URL, "Groq") self.assertIn("Groq", str(caught.exception)) + def test_gemini_keeps_only_the_models_that_answer_a_chat_request(self): + with fake_urlopen({"data": [{"id": "gemini-3.5-flash"}, + {"id": "text-embedding-004"}, + {"id": "imagen-4.0"}, + {"id": "gemini-2.5-flash-lite"}]}) as calls: + models = api.gemini_models("AIza-test") + self.assertEqual(calls[0].full_url, + "https://generativelanguage.googleapis.com/v1beta/openai/models") + self.assertEqual(models, ["gemini-2.5-flash-lite", "gemini-3.5-flash"]) + + def test_the_long_form_of_an_id_is_shortened_to_what_a_request_wants(self): + with fake_urlopen({"data": [{"id": "models/gemini-3.5-flash-lite"}]}): + self.assertEqual(api.gemini_models("AIza-test"), + ["gemini-3.5-flash-lite"]) + + def test_a_gemini_id_that_is_not_a_chat_model_is_left_out(self): + """Google names its pictures and its voices `gemini` too.""" + with fake_urlopen({"data": [{"id": "gemini-3.5-flash"}, + {"id": "gemini-embedding-001"}, + {"id": "gemini-2.5-flash-image"}, + {"id": "gemini-2.5-flash-preview-tts"}, + {"id": "gemini-2.5-native-audio"}]}): + self.assertEqual(api.gemini_models("AIza-test"), ["gemini-3.5-flash"]) + + def test_gemini_sends_the_key_as_a_bearer_token(self): + with fake_urlopen({"data": []}) as calls: + api.gemini_models("AIza-test") + self.assertEqual(calls[0].get_header("Authorization"), "Bearer AIza-test") + + def test_a_missing_gemini_key_says_google_ai_studio(self): + with self.assertRaises(api.ApiError) as caught: + api.gemini_models("") + self.assertIn("Google AI Studio", str(caught.exception)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_assistant.py b/tests/test_assistant.py index 1c682c6..e9212be 100644 --- a/tests/test_assistant.py +++ b/tests/test_assistant.py @@ -58,15 +58,36 @@ class Provider(DikteTest): def test_what_each_one_runs(self): self.assertEqual(assistant.executable("claude"), "claude") self.assertEqual(assistant.executable("codex"), "codex") + self.assertEqual(assistant.executable("agy"), "agy") self.assertEqual(assistant.executable("openrouter"), "") + def test_the_model_recorded_is_the_one_that_answered(self): + """The history used to write Claude's setting whoever had answered.""" + self.assertEqual(assistant.model(self.config()), "sonnet") + self.assertEqual( + assistant.model(self.config(assistant_provider="codex")), "codex") + self.assertEqual( + assistant.model(self.config(assistant_provider="agy")), "agy") + self.assertEqual( + assistant.model(self.config(assistant_provider="agy", + assistant_agy_model="gemini-3.1-pro-low")), + "gemini-3.1-pro-low") + self.assertEqual( + assistant.model(self.config(assistant_provider="openrouter")), + "google/gemini-3.5-flash") + def test_what_each_one_is_called(self): self.assertEqual(assistant.display_name(self.config()), "Claude") - self.assertEqual( - assistant.display_name(self.config(assistant_provider="codex")), "Codex") - self.assertEqual( - assistant.display_name(self.config(assistant_provider="openrouter")), - "OpenRouter") + for name, called in (("codex", "Codex"), ("agy", "Antigravity"), + ("openrouter", "OpenRouter")): + with self.subTest(name=name): + self.assertEqual( + assistant.display_name(self.config(assistant_provider=name)), + called) + + def test_every_provider_has_a_name_to_be_called_by(self): + """_conclude writes its errors in it, so a gap here is a bare id.""" + self.assertEqual(set(assistant.SERVICES), set(assistant.PROVIDERS)) class Effort(unittest.TestCase): @@ -74,21 +95,26 @@ class Effort(unittest.TestCase): def test_the_scales_cover_the_same_settings(self): self.assertEqual(set(assistant.CLAUDE_EFFORT), set(assistant.CODEX_EFFORT)) + self.assertEqual(set(assistant.CLAUDE_EFFORT), set(assistant.AGY_EFFORT)) - def test_codex_has_no_rung_above_high(self): - self.assertEqual(assistant.CODEX_EFFORT["xhigh"], "high") - self.assertEqual(assistant.CODEX_EFFORT["max"], "high") + def test_neither_codex_nor_agy_has_a_rung_above_high(self): + for scale in (assistant.CODEX_EFFORT, assistant.AGY_EFFORT): + self.assertEqual(scale["xhigh"], "high") + self.assertEqual(scale["max"], "high") - def test_neither_one_asks_for_a_rung_below_low(self): + def test_none_of_them_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): + # models and "none" on the newer ones, and refuses the wrong word; agy + # has three rungs and no word for off at all. + for scale in (assistant.CLAUDE_EFFORT, assistant.CODEX_EFFORT, + assistant.AGY_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("", ""), "") - self.assertEqual(assistant.CODEX_EFFORT.get("", ""), "") + for scale in (assistant.CLAUDE_EFFORT, assistant.CODEX_EFFORT, + assistant.AGY_EFFORT): + self.assertEqual(scale.get("", ""), "") class Session(DikteTest): @@ -249,45 +275,52 @@ class Conclude(DikteTest): def test_an_answer_and_its_session(self): answer, warning = assistant._conclude( - self.found(answer="done", session="abc"), 0, "", "", "Claude") + self.found(answer="done", session="abc"), 0, "", "", "claude") self.assertEqual(answer, "done") self.assertEqual(warning, "") self.assertEqual(assistant.read_session("claude", 1800), "abc") - def test_codex_stores_under_its_own_name(self): - assistant._conclude(self.found(answer="done", session="t-1"), 0, "", - "", "Codex") - self.assertEqual(assistant.read_session("codex", 1800), "t-1") + def test_each_one_stores_under_its_own_name(self): + for name, session in (("codex", "t-1"), ("agy", "c-9")): + with self.subTest(name=name): + assistant._conclude(self.found(answer="done", session=session), + 0, "", "", name) + self.assertEqual(assistant.read_session(name, 1800), session) + + def test_the_error_is_written_in_the_provider_s_own_name(self): + with self.assertRaises(assistant.AssistantError) as caught: + assistant._conclude(self.found(), 1, "", "", "agy") + self.assertIn("Antigravity", str(caught.exception)) def test_a_non_zero_exit_with_nothing_to_show_for_it(self): with self.assertRaises(assistant.AssistantError) as caught: - assistant._conclude(self.found(), 1, "it all went wrong\n", "", "Claude") + assistant._conclude(self.found(), 1, "it all went wrong\n", "", "claude") self.assertIn("it all went wrong", str(caught.exception)) def test_a_session_that_is_gone_is_raised_apart(self): with self.assertRaises(assistant._SessionGone): assistant._conclude(self.found(), 1, "session abc not found", - "abc", "Claude") + "abc", "claude") def test_a_session_that_is_gone_only_matters_when_one_was_resumed(self): with self.assertRaises(assistant.AssistantError): assistant._conclude(self.found(), 1, "session abc not found", - "", "Claude") + "", "claude") def test_an_answer_survives_a_non_zero_exit(self): answer, _ = assistant._conclude(self.found(answer="done"), 1, "noise", - "", "Claude") + "", "claude") self.assertEqual(answer, "done") def test_a_reported_failure_with_no_answer(self): with self.assertRaises(assistant.AssistantError) as caught: assistant._conclude(self.found(failure="the model refused"), 0, "", - "", "Claude") + "", "claude") self.assertIn("refused", str(caught.exception)) def test_a_run_that_said_nothing_at_all(self): with self.assertRaises(assistant.AssistantError) as caught: - assistant._conclude(self.found(), 0, "", "", "Codex") + assistant._conclude(self.found(), 0, "", "", "codex") self.assertIn("Codex", str(caught.exception)) @@ -472,6 +505,109 @@ class AskCodex(DikteTest): self.assertIn("quota", str(caught.exception)) +class AskAgy(DikteTest): + """agy's stream is shaped nothing like the other two: the key is `event`, + the answer arrives whole in `result.response`, and the conversation to + resume is named in the first line rather than the last.""" + + def run_ask(self, conf=None, events=None, session=""): + conf = conf or self.config(assistant_provider="agy") + proc = FakeCli(events or [ + {"event": "init", "conversation_id": "c-9", "init": {"cwd": "/home"}}, + {"event": "result", + "result": {"conversation_id": "c-9", "status": "SUCCESS", + "response": " done "}}, + ]) + stages = [] + with only_these_tools("agy"), \ + mock.patch.object(subprocess, "Popen", return_value=proc) as popen: + result = assistant._ask_agy("book it", conf, session, + stages.append, None) + return result, popen.call_args.args[0], stages + + def test_the_answer_comes_back_stripped(self): + (answer, warning), _, _ = self.run_ask() + self.assertEqual(answer, "done") + self.assertEqual(warning, "") + + def test_the_instruction_is_kept_apart_from_the_command(self): + """agy takes no system prompt, so the two must not read as one.""" + conf = self.config(assistant_provider="agy") + _, cmd, _ = self.run_ask(conf) + body = cmd[cmd.index("-p") + 1] + self.assertTrue(body.startswith(conf.assistant_prompt())) + self.assertIn("\n\n---\n\n", body) + self.assertTrue(body.endswith("book it")) + + def test_a_first_command_starts_a_project_of_its_own(self): + """Without it agy works in whichever project it was last in.""" + _, cmd, _ = self.run_ask() + self.assertIn("--new-project", cmd) + self.assertNotIn("--conversation", cmd) + + def test_a_second_command_carries_the_conversation_rather_than_starting_one(self): + _, cmd, _ = self.run_ask(session="c-9") + self.assertEqual(cmd[cmd.index("--conversation") + 1], "c-9") + self.assertNotIn("--new-project", cmd) + + def test_the_conversation_is_kept_under_agy_s_own_name(self): + self.run_ask() + self.assertEqual(assistant.read_session("agy", 1800), "c-9") + + def test_it_is_not_left_to_give_up_before_the_caller_does(self): + conf = self.config(assistant_provider="agy", assistant_timeout=90) + _, cmd, _ = self.run_ask(conf) + self.assertEqual(cmd[cmd.index("--print-timeout") + 1], "90s") + + def test_no_model_named_means_whatever_agy_is_set_to(self): + _, cmd, _ = self.run_ask() + self.assertNotIn("--model", cmd) + + def test_a_model_of_your_own(self): + _, cmd, _ = self.run_ask( + self.config(assistant_provider="agy", + assistant_agy_model="gemini-3.1-pro-low")) + self.assertEqual(cmd[cmd.index("--model") + 1], "gemini-3.1-pro-low") + + def test_a_tool_is_named_in_the_corner_as_it_starts(self): + _, _, stages = self.run_ask(events=[ + {"event": "step_update", + "step_update": {"step_type": "tool", "state": "ACTIVE", + "tool_name": "run_command"}}, + {"event": "step_update", + "step_update": {"step_type": "tool", "state": "DONE", + "tool_name": "run_command"}}, + {"event": "result", + "result": {"status": "SUCCESS", "response": "done"}}, + ]) + self.assertEqual(stages, ["Running a command…"]) + + def test_the_two_dozen_browser_tools_are_one_line_between_them(self): + _, _, stages = self.run_ask(events=[ + {"event": "step_update", + "step_update": {"step_type": "tool", "state": "ACTIVE", + "tool_name": "browser_click_element"}}, + {"event": "result", + "result": {"status": "SUCCESS", "response": "done"}}, + ]) + self.assertEqual(stages, ["Working in the browser…"]) + + def test_a_turn_that_did_not_succeed_is_a_failure_rather_than_an_answer(self): + with self.assertRaises(assistant.AssistantError) as caught: + self.run_ask(events=[ + {"event": "result", + "result": {"status": "ERROR", "response": "the model refused"}}, + ]) + self.assertIn("refused", str(caught.exception)) + + def test_a_failure_with_nothing_to_say_is_still_named(self): + with self.assertRaises(assistant.AssistantError) as caught: + self.run_ask(events=[ + {"event": "result", "result": {"status": "ERROR"}}, + ]) + self.assertIn("Antigravity", str(caught.exception)) + + class AskOpenRouter(DikteTest): def test_a_question_and_an_answer(self): conf = self.config(assistant_provider="openrouter", diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py index 7753029..736c647 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -49,7 +49,9 @@ class Provider(DikteTest): def test_what_each_one_runs(self): self.assertEqual(cleanup.executable("claude"), "claude") self.assertEqual(cleanup.executable("codex"), "codex") + self.assertEqual(cleanup.executable("agy"), "agy") self.assertEqual(cleanup.executable("openrouter"), "") + self.assertEqual(cleanup.executable("gemini"), "") 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")), @@ -65,6 +67,16 @@ class Provider(DikteTest): self.assertEqual( cleanup.model(self.config(cleanup_provider="codex", cleanup_codex_model="gpt-5.4")), "gpt-5.4") + self.assertEqual( + cleanup.model(self.config(cleanup_provider="gemini")), + "gemini-3.5-flash-lite") + # Antigravity is left on its own default the way Codex is. + self.assertEqual( + cleanup.model(self.config(cleanup_provider="agy")), "agy") + self.assertEqual( + cleanup.model(self.config(cleanup_provider="agy", + cleanup_agy_model="gemini-3.7-flash-low")), + "gemini-3.7-flash-low") class OpenRouter(DikteTest): @@ -86,6 +98,46 @@ class OpenRouter(DikteTest): self.assertEqual(calls, []) +class GoogleAiStudio(DikteTest): + """Cleanup over Google's OpenAI-compatible endpoint: one request, no CLI.""" + + def setUp(self): + super().setUp() + self.conf = self.config(cleanup_provider="gemini", + gemini_api_key="AIza-test") + + def test_it_goes_to_google_with_the_settings_as_they_were(self): + self.conf["cleanup_reasoning"] = "none" + with fake_urlopen(chat_reply("Done.")) as calls: + self.assertEqual(cleanup.run("uh, done", self.conf, "the rules"), + "Done.") + self.assertEqual( + calls[0].full_url, + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions") + payload = sent_json(calls[0]) + self.assertEqual(payload["model"], "gemini-3.5-flash-lite") + self.assertEqual(payload["reasoning_effort"], "none") + self.assertIn("uh, done", payload["messages"][1]["content"]) + + def test_the_key_travels_as_a_bearer_token(self): + with fake_urlopen(chat_reply("Done.")) as calls: + cleanup.run("uh, done", self.conf, "the rules") + self.assertEqual(calls[0].get_header("Authorization"), "Bearer AIza-test") + + def test_a_missing_key_names_google_rather_than_openrouter(self): + self.conf["gemini_api_key"] = "" + with mock.patch.dict(os.environ, {}, clear=True), \ + self.assertRaises(api.ApiError) as caught: + cleanup.run("uh, done", self.conf, "the rules") + self.assertIn("Google AI Studio", 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, []) + + class ClaudeCode(DikteTest): def setUp(self): super().setUp() @@ -209,6 +261,63 @@ class Codex(DikteTest): self.run_cleanup(stdout="tokens used 400", last_message="") +class Antigravity(DikteTest): + def setUp(self): + super().setUp() + self.conf = self.config(cleanup_provider="agy") + 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(stdout="Book it.\n") + self.assertEqual(answer, "Book it.") + self.assertEqual(cmd[0], "agy") + self.assertEqual(cmd[cmd.index("-p") + 1], + "the rules\n\n---\n\n\nuh, book it\n") + + def test_it_starts_somewhere_of_its_own_and_takes_no_slash_commands(self): + """Without --new-project agy works in whichever project it was last in.""" + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertIn("--new-project", cmd) + self.assertIn("--disable-slash-commands", cmd) + self.assertEqual(cmd[cmd.index("--output-format") + 1], "text") + + def test_it_is_not_left_to_give_up_before_the_caller_does(self): + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertEqual(cmd[cmd.index("--print-timeout") + 1], "180s") + + def test_the_model_is_left_alone_until_one_is_typed_in(self): + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertNotIn("--model", cmd) + self.conf["cleanup_agy_model"] = "gemini-3.7-flash-low" + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertEqual(cmd[cmd.index("--model") + 1], "gemini-3.7-flash-low") + + def test_the_thinking_setting_lands_on_the_nearest_rung_agy_has(self): + self.conf["cleanup_reasoning"] = "max" + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertEqual(cmd[cmd.index("--effort") + 1], "high") + + def test_no_thinking_setting_means_no_flag(self): + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertNotIn("--effort", cmd) + + def test_an_answer_of_nothing_is_a_failure_rather_than_an_empty_paste(self): + with self.assertRaises(cleanup.CleanupError): + self.run_cleanup(stdout=" ") + + 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("agy", str(caught.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 585bad5..dc06388 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,6 +15,7 @@ from typing import ClassVar from unittest import mock from dikte import audio +from dikte import cleanup from dikte import cli from dikte import config as cfg from dikte import ggml @@ -503,6 +504,26 @@ class Doctor(DikteTest): self.assertIn("OpenRouter key, cleaning up on some/model", self.run_doctor(as_json=False, cleanup_model="some/model")) + def test_it_survives_every_provider_cleanup_can_be_set_to(self): + """It used to raise KeyError on the local model, whose executable is "".""" + for name in cleanup.PROVIDERS: + with self.subTest(provider=name): + reply = self.run_doctor(cleanup_provider=name) + self.assertEqual(reply["cleanup"]["provider"], name) + self.run_doctor(as_json=False, cleanup_provider=name) + + def test_a_provider_with_no_key_to_check_says_so_rather_than_no(self): + """A CLI needs none, so `false` there would read as one gone missing.""" + self.assertIsNone(self.run_doctor(cleanup_provider="claude")["cleanup"]["key"]) + self.assertIsNone(self.run_doctor(cleanup_provider="local")["cleanup"]["key"]) + self.assertIs(self.run_doctor(cleanup_provider="gemini")["cleanup"]["key"], + False) + + def test_cleanup_on_google_is_a_question_about_its_own_key(self): + line = self.run_doctor(as_json=False, cleanup_provider="gemini", + cleanup_gemini_model="gemini-2.5-flash") + self.assertIn("Google AI Studio key, cleaning up on gemini-2.5-flash", line) + def test_it_asks_after_the_programs_this_desktop_actually_uses(self): """A missing ydotool on a Mac is a red mark with nothing behind it.""" with mock.patch.object(cli.paste, "desktop", return_value=paste.MACOS): diff --git a/tests/test_config.py b/tests/test_config.py index 9d8e372..db584d9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -134,6 +134,8 @@ class Keys(DikteTest): 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") + with mock.patch.dict(os.environ, {"GEMINI_API_KEY": "AIza-env"}): + self.assertEqual(cfg.Config().gemini_key(), "AIza-env") class TranscribeTarget(DikteTest): @@ -449,6 +451,12 @@ class Defaults(unittest.TestCase): def test_the_keys_ship_empty(self): self.assertEqual(cfg.DEFAULTS["openai_api_key"], "") self.assertEqual(cfg.DEFAULTS["openrouter_api_key"], "") + self.assertEqual(cfg.DEFAULTS["gemini_api_key"], "") + + def test_google_ai_studio_is_a_cleanup_provider_and_not_a_transcriber(self): + """Its compatible endpoint has no /audio/transcriptions behind it.""" + self.assertNotIn("gemini", cfg.TRANSCRIBERS) + self.assertIn("gemini", cleanup.PROVIDERS) def test_every_language_specific_prompt_has_both_languages(self): for name in ("CLEANUP_PROMPT", "FILE_CLEANUP_PROMPT", "MEETING_PROMPT", diff --git a/tests/test_ui.py b/tests/test_ui.py index 0b04be9..4771ae4 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -50,6 +50,7 @@ CHANGED = { "openai_api_key": "sk-test-key", "groq_api_key": "gsk-test-key", "openrouter_api_key": "sk-or-test-key", + "gemini_api_key": "AIza-test-key", "transcribe_provider": "openrouter", "transcribe_model": "whisper-1", "groq_transcribe_model": "whisper-large-v3", @@ -59,6 +60,8 @@ CHANGED = { "cleanup_model": "some/other-model", "cleanup_claude_model": "opus", "cleanup_codex_model": "gpt-5", + "cleanup_gemini_model": "gemini-2.5-flash", + "cleanup_agy_model": "gemini-3.1-pro-low", "cleanup_reasoning": "high", "local_model": "ggml-small.bin", "local_gpu": False, @@ -78,6 +81,7 @@ CHANGED = { "assistant_codex_model": "gpt-5", "assistant_codex_sandbox": "read-only", "assistant_openrouter_model": "some/agent-model", + "assistant_agy_model": "gemini-3.1-pro-low", "assistant_reasoning": "high", "assistant_dir": "/tmp", "assistant_timeout": 600, @@ -754,3 +758,29 @@ class LocalModels(DikteTest): 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)) + + def test_each_cleaner_brings_its_own_model_row_and_no_other(self): + window = self.window(cfg.Config()) + rows = {"openrouter": window.cleanup_model_row, + "gemini": window.cleanup_gemini_model_row, + "claude": window.cleanup_claude_model, + "codex": window.cleanup_codex_model, + "agy": window.cleanup_agy_model} + for chosen, row in rows.items(): + with self.subTest(provider=chosen): + window._select_data(window.cleanup_provider, chosen) + for name, other in rows.items(): + self.assertEqual(window.cleanup_form.isRowVisible(other), + name == chosen) + + def test_each_agent_brings_its_own_box_and_no_other(self): + window = self.window(cfg.Config()) + boxes = {"claude": window.claude_box, "codex": window.codex_box, + "agy": window.agy_box, "openrouter": window.openrouter_box} + for chosen, box in boxes.items(): + with self.subTest(provider=chosen): + window._select_data(window.assistant_provider, chosen) + for name, other in boxes.items(): + # isHidden rather than isVisible: the window itself is never + # shown in a test, so nothing in it is ever visible. + self.assertEqual(other.isHidden(), name != chosen) From 1ffc3cff9d3a2192ddaa7591fefba47ec585e3d1 Mon Sep 17 00:00:00 2001 From: oztturk Date: Wed, 26 Aug 2026 16:29:41 +0300 Subject: [PATCH 2/6] Let an error body that is an array still be read Google answers some failures with a JSON array holding the object every other provider sends on its own. _extract_error called .get() on it and raised AttributeError, which is not the ApiError every caller is holding, so a 503 from Google took the whole dictation down instead of pasting the raw transcript with the failure shown beside it. Found by dictating against a Google AI Studio outage: HTTP Error 503: Service Unavailable AttributeError: 'list' object has no attribute 'get' It runs while an exception is being raised, so it now ends in a string whatever arrives. Co-Authored-By: Claude Opus 5 --- dikte/api.py | 13 +++++++++++++ tests/test_api.py | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/dikte/api.py b/dikte/api.py index 16c85c8..111099d 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -262,10 +262,23 @@ def _request(url, data, headers, timeout=120, aborter=None): def _extract_error(body): + """The line worth showing out of a failed request's body. + + Whatever comes back, this has to end in a string: it is called while an + ApiError is being raised, and an exception thrown here would escape the + `except ApiError` every caller is holding and lose the dictation the raw + transcript would otherwise have been pasted from. + """ try: payload = json.loads(body) except json.JSONDecodeError: return body[:300] + if isinstance(payload, list): + # Google answers some failures with an array holding the object the + # other providers send on its own. + payload = next((item for item in payload if isinstance(item, dict)), None) + if not isinstance(payload, dict): + return body[:300] err = payload.get("error") if isinstance(err, dict): return err.get("message") or json.dumps(err)[:300] diff --git a/tests/test_api.py b/tests/test_api.py index 09b49e1..ccaa527 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -119,9 +119,22 @@ class ExtractError(unittest.TestCase): body = json.dumps({"error": {"code": 42}}) self.assertIn("42", api._extract_error(body)) + def test_an_error_wrapped_in_an_array(self): + """Google's 503 arrives this way, and .get() on a list raises.""" + body = json.dumps([{"error": {"code": 503, + "message": "The model is overloaded."}}]) + self.assertEqual(api._extract_error(body), "The model is overloaded.") + def test_a_body_that_is_not_json(self): self.assertEqual(api._extract_error("502"), "502") + def test_no_shape_at_all_still_comes_back_as_a_string(self): + """It runs while an ApiError is being raised: throwing here would + escape the `except ApiError` holding the raw transcript.""" + for body in ("[]", "[1, 2]", '"a string"', "null", "17"): + with self.subTest(body=body): + self.assertIsInstance(api._extract_error(body), str) + def test_a_wall_of_html_is_cut_short(self): self.assertEqual(len(api._extract_error("x" * 5000)), 300) From 7ebc3bf825aeaaab6a60936b1225b0cbc2073aa1 Mon Sep 17 00:00:00 2001 From: oztturk Date: Wed, 26 Aug 2026 16:40:27 +0300 Subject: [PATCH 3/6] Ask Google for the lowest rung it has rather than for none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google's compatibility layer has no word for off. Sending reasoning_effort "none" is refused outright, so choosing Thinking → Off made every cleanup fail and paste the raw transcript instead: HTTP 400: Request contains an invalid argument. (INVALID_ARGUMENT) Measured against gemini-3.5-flash-lite, asking it to reply "ok": nothing sent 50.59s reasoning_effort "none" 400 reasoning_effort "minimal" 15.40s reasoning_effort "low" 53.35s reasoning_effort "high" 63.83s So "none" lands on "minimal", which is both accepted and the quickest of them, and quickest is what cleanup wants. The thinking_config route the documentation offers is an SDK wrapper and is not a field this endpoint knows: sending it is "Unknown name \"google\"". Co-Authored-By: Claude Opus 5 --- dikte/api.py | 20 +++++++++++--------- tests/test_api.py | 6 +++--- tests/test_cleanup.py | 2 +- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/dikte/api.py b/dikte/api.py index 111099d..98a6031 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -469,11 +469,13 @@ def transcribe_segments(target, audio_path, language="", prompt="", timeout=300, return out -# The settings window offers OpenRouter's ladder, whose top two rungs Google -# has never heard of. Sent as they are the request is refused, which costs the -# cleanup rather than the dictation, so they land on the nearest rung that -# exists instead. -GEMINI_EFFORT = {"xhigh": "high", "max": "high"} +# The settings window offers OpenRouter's ladder, and Google has neither end of +# it: "none" is refused outright with a 400, and there is nothing above "high". +# Both ends land on the nearest rung that does exist, which costs the cleanup +# rather than the dictation when it is wrong. "minimal" is where "off" goes, and +# it is the quickest of them by a wide margin, which is what cleanup wants +# anyway. +GEMINI_EFFORT = {"none": "minimal", "xhigh": "high", "max": "high"} def _thinking(payload, provider, reasoning): @@ -494,10 +496,10 @@ def _thinking(payload, provider, reasoning): payload["chat_template_kwargs"] = {"enable_thinking": reasoning != "none"} elif provider == "gemini": # Google's compatibility layer takes OpenAI's flat field rather than - # OpenRouter's object, and "none" is how thinking is turned off there, - # so it is the one level worth sending rather than skipping: a Flash - # model left to think spends exactly the second this provider was - # chosen to save. + # OpenRouter's object, and it has no word for off, so "none" is asked + # for as the lowest rung it has rather than skipped: a Flash model left + # to decide for itself thinks, and thinking about a comma is the second + # this provider was chosen to save. payload["reasoning_effort"] = GEMINI_EFFORT.get(reasoning, reasoning) elif reasoning != "none": # The thinking itself is never shown, so ask for it to be left out. diff --git a/tests/test_api.py b/tests/test_api.py index ccaa527..c914017 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -404,11 +404,11 @@ class Cleanup(DikteTest): self.assertEqual(payload["reasoning_effort"], "low") self.assertNotIn("reasoning", payload) - def test_gemini_is_told_to_stop_thinking_rather_than_left_alone(self): - """"none" is the one level worth sending: Flash thinks by default.""" + def test_off_is_asked_for_as_the_lowest_rung_google_actually_has(self): + """Sending "none" is a 400, and Flash left alone thinks.""" _, calls = self.call(chat_reply("Hello."), reasoning="none", provider="gemini", service="Google AI Studio") - self.assertEqual(sent_json(calls[0])["reasoning_effort"], "none") + self.assertEqual(sent_json(calls[0])["reasoning_effort"], "minimal") def test_a_rung_google_does_not_have_lands_on_the_nearest_one(self): for asked in ("xhigh", "max"): diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py index 736c647..4996b44 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -116,7 +116,7 @@ class GoogleAiStudio(DikteTest): "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions") payload = sent_json(calls[0]) self.assertEqual(payload["model"], "gemini-3.5-flash-lite") - self.assertEqual(payload["reasoning_effort"], "none") + self.assertEqual(payload["reasoning_effort"], "minimal") self.assertIn("uh, done", payload["messages"][1]["content"]) def test_the_key_travels_as_a_bearer_token(self): From e6208418f928eb9a557efa2bf47970d8c1d7740a Mon Sep 17 00:00:00 2001 From: Aydogan Date: Thu, 27 Aug 2026 07:46:34 +0300 Subject: [PATCH 4/6] Add display selection for recording indicator --- README.md | 16 +++++++++++----- README.tr.md | 11 +++++++++-- dikte/app.py | 8 ++++++-- dikte/config.py | 1 + dikte/i18n.py | 4 ++++ dikte/overlay.py | 10 ++++++++-- dikte/settings_ui.py | 32 +++++++++++++++++++++++++++----- tests/test_ui.py | 14 +++++++++++++- 8 files changed, 79 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index fe36166..85be8fc 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,17 @@ run here by default, on models of your own. The cloud is the other option: speech to text on **OpenAI**, **Groq** or **OpenRouter** (`gpt-4o-transcribe`), 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, or in -`~/Library/Application Support/Dikte` on a Mac. 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. +`GROQ_API_KEY` and `OPENROUTER_API_KEY`. + +All settings, including the indicator screen and corner, are stored outside the +application: in `$XDG_CONFIG_HOME/dikte/config.json` (normally +`~/.config/dikte/config.json`) on Linux, `%APPDATA%\Dikte\config.json` on +Windows, and `~/Library/Application Support/Dikte/config.json` on macOS. The +file is mode 600 where Unix permissions apply. Rebuilding, replacing or +updating Dikte therefore keeps the settings. Only removing that file or +uninstalling with `--purge` resets them. 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. ## Using it diff --git a/README.tr.md b/README.tr.md index 5e88c69..0457c52 100644 --- a/README.tr.md +++ b/README.tr.md @@ -114,8 +114,15 @@ 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, Mac'te ise `~/Library/Application Support/Dikte` altında. +`OPENROUTER_API_KEY` kullanılır. + +Gösterge ekranı ve köşesi dahil bütün ayarlar uygulamanın dışında saklanır: +Linux'ta `$XDG_CONFIG_HOME/dikte/config.json` (normalde +`~/.config/dikte/config.json`), Windows'ta `%APPDATA%\Dikte\config.json`, +macOS'ta `~/Library/Application Support/Dikte/config.json`. Unix izinlerinin +geçerli olduğu sistemlerde dosyanın izni 600'dür. Bu nedenle Dikte'yi yeniden +derlemek, değiştirmek veya güncellemek ayarları silmez. Yalnızca bu dosyayı +silmek ya da kaldırıcıyı `--purge` ile çalıştırmak ayarları sıfırlar. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin. diff --git a/dikte/app.py b/dikte/app.py index 1da104e..5fb2837 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -146,11 +146,13 @@ class Dikte: # started it cannot stop the one that came after. self._run_id = 0 - self.overlay = Overlay(self.conf["overlay_corner"]) + self.overlay = Overlay(self.conf["overlay_corner"], + screen_name=self.conf["overlay_screen"]) # The agent's indicator sits on top of the dictation one when both are # up, and drops into the corner when it is alone there. self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay, - dismissable=True) + dismissable=True, + screen_name=self.conf["overlay_screen"]) self.recorder = audio.Recorder() self.pipeline = Pipeline(self.conf) self.ask_pipeline = Pipeline(self.conf) @@ -1049,7 +1051,9 @@ class Dikte: def _apply_settings(self): self.overlay.corner = self.conf["overlay_corner"] + self.overlay.screen_name = self.conf["overlay_screen"] self.ask_overlay.corner = self.conf["overlay_corner"] + self.ask_overlay.screen_name = self.conf["overlay_screen"] self._apply_local() self._build_tray() self._refresh_tray() diff --git a/dikte/config.py b/dikte/config.py index fb7683a..eeaa704 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -455,6 +455,7 @@ DEFAULTS = { "pause_shortcut": "", "evdev_hotkey": False, "overlay_corner": "bottom-left", + "overlay_screen": "", "keep_audio": False, "history_limit": 200, # A look at the releases page once a day, and nothing more than a look: diff --git a/dikte/i18n.py b/dikte/i18n.py index f7f4910..56ec263 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -150,6 +150,7 @@ TR = { # --- settings: tabs and general ------------------------------------ "Dikte Settings": "Dikte Ayarları", "General": "Genel", + "Display": "Görüntü", "API and models": "API ve modeller", "Cleanup rules": "Temizleme kuralları", "Audio file": "Ses dosyası", @@ -180,6 +181,9 @@ TR = { "macOS bu ilk gönderildiğinde Erişilebilirlik izni ister.", "Restore the previous clipboard after pasting": "Yapıştırdıktan sonra eski pano içeriğini geri koy", + "Indicator screen": "Gösterge ekranı", + "Follow the mouse pointer": "Fare imlecini takip et", + "{name} (not connected)": "{name} (bağlı değil)", "Indicator corner": "Gösterge köşesi", "bottom-left": "sol-alt", "bottom-right": "sağ-alt", diff --git a/dikte/overlay.py b/dikte/overlay.py index 4f4d3f1..a4acbe3 100644 --- a/dikte/overlay.py +++ b/dikte/overlay.py @@ -40,9 +40,11 @@ class Overlay(QWidget): of covering it, which is what lets a dictation and a command to the agent be under way at the same time and still both be visible.""" - def __init__(self, corner="bottom-left", below=None, dismissable=False): + def __init__(self, corner="bottom-left", below=None, dismissable=False, + screen_name=""): super().__init__(None) self.corner = corner + self.screen_name = screen_name self.below = below # A job that can run for ten minutes should not have to be watched for # ten minutes. Clicking such an indicator puts the progress away; the @@ -245,7 +247,11 @@ class Overlay(QWidget): def _reposition(self): # On a multi-monitor setup, show up where the user actually is. - screen = QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen() + screen = next( + (item for item in QApplication.screens() if item.name() == self.screen_name), + None, + ) + screen = screen or QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen() area = screen.availableGeometry() left = "left" in self.corner top = "top" in self.corner diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index d3a97b7..a032ffc 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -553,6 +553,7 @@ class SettingsWindow(QDialog): tabs = self.tabs = QTabWidget(self) tabs.addTab(self._scrolled(self._general_tab()), t("General")) + tabs.addTab(self._scrolled(self._display_tab()), t("Display")) self.api_tab_index = tabs.addTab( self._scrolled(self._api_tab()), t("API and models")) tabs.addTab(self._scrolled(self._prompt_tab()), t("Cleanup rules")) @@ -674,11 +675,6 @@ class SettingsWindow(QDialog): self.restore_clipboard = QCheckBox(t("Restore the previous clipboard after pasting")) form.addRow("", self.restore_clipboard) - self.corner = QComboBox() - for value in CORNERS: - self.corner.addItem(t(value), value) - form.addRow(t("Indicator corner"), self.corner) - self.max_seconds = QSpinBox() self.max_seconds.setRange(10, 3600) self.max_seconds.setSuffix(t(" s")) @@ -729,6 +725,27 @@ class SettingsWindow(QDialog): self.update_now)) return page + def _display_tab(self): + page = QWidget() + form = QFormLayout(page) + + self.indicator_screen = QComboBox() + self.indicator_screen.addItem(t("Follow the mouse pointer"), "") + for screen in QGuiApplication.screens(): + area = screen.geometry() + self.indicator_screen.addItem( + t("{name} ({width} × {height})", name=screen.name(), + width=area.width(), height=area.height()), + screen.name(), + ) + form.addRow(t("Indicator screen"), self.indicator_screen) + + self.corner = QComboBox() + for value in CORNERS: + self.corner.addItem(t(value), value) + form.addRow(t("Indicator corner"), self.corner) + return page + def _api_tab(self): page = QWidget() outer = QVBoxLayout(page) @@ -1591,6 +1608,10 @@ class SettingsWindow(QDialog): self.auto_paste.setChecked(conf["auto_paste"]) self.paste_shortcut.setCurrentText(conf["paste_shortcut"]) self.restore_clipboard.setChecked(conf["restore_clipboard"]) + screen_name = conf["overlay_screen"] + if screen_name and self.indicator_screen.findData(screen_name) < 0: + self.indicator_screen.addItem(t("{name} (not connected)", name=screen_name), screen_name) + self._select_data(self.indicator_screen, screen_name) self._select_data(self.corner, conf["overlay_corner"]) self.max_seconds.setValue(conf["max_seconds"]) self.skip_silent.setChecked(conf["skip_silent"]) @@ -1686,6 +1707,7 @@ class SettingsWindow(QDialog): conf["auto_paste"] = self.auto_paste.isChecked() conf["paste_shortcut"] = self.paste_shortcut.currentText().strip() conf["restore_clipboard"] = self.restore_clipboard.isChecked() + conf["overlay_screen"] = self.indicator_screen.currentData() or "" conf["overlay_corner"] = self.corner.currentData() or "bottom-left" conf["max_seconds"] = self.max_seconds.value() conf["skip_silent"] = self.skip_silent.isChecked() diff --git a/tests/test_ui.py b/tests/test_ui.py index 0b04be9..104be0d 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -42,6 +42,7 @@ CHANGED = { "paste_shortcut": "ctrl+shift+v", "restore_clipboard": True, "overlay_corner": "top-right", + "overlay_screen": "DP-1", "max_seconds": 120, "skip_silent": False, "silence_db": -42.0, @@ -159,7 +160,7 @@ class Settings(DikteTest): def test_the_window_opens_with_every_tab_on_it(self): window = self.window(cfg.Config()) tabs = window.findChildren(settings_ui.QTabWidget)[0] - self.assertEqual(tabs.count(), 9) + self.assertEqual(tabs.count(), 10) self.assertEqual(window.windowTitle(), "Dikte Settings") def test_no_tab_can_stretch_the_window_past_a_small_screen(self): @@ -596,6 +597,17 @@ class Overlay(DikteTest): widget.show_recording() widget._reposition() + def test_a_named_screen_is_used_instead_of_the_pointer_screen(self): + screen = mock.Mock() + screen.name.return_value = "DP-1" + screen.availableGeometry.return_value = settings_ui.QRect(1920, 0, 1920, 1080) + widget = self.overlay(screen_name="DP-1") + with mock.patch.object(QApplication, "screens", return_value=[screen]), \ + mock.patch.object(QApplication, "screenAt") as screen_at: + widget._reposition() + screen_at.assert_not_called() + self.assertEqual(widget.pos(), QPoint(1948, 995)) + def test_a_warning_and_an_error_both_show(self): widget = self.overlay() widget.show_warning("cleanup failed") From 23243c517123a9869a370b604273908ffce4225a Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 27 Aug 2026 15:52:02 +0300 Subject: [PATCH 5/6] Trim the README and polish the display selection The settings-storage paragraph goes back to the one sentence it was. The screen list now shows native resolutions rather than the scaled ones, the Turkish tab is named Ekran, and the repositioning comment says what a named screen changes. --- README.md | 16 +++++----------- README.tr.md | 11 ++--------- dikte/i18n.py | 2 +- dikte/overlay.py | 5 ++++- dikte/settings_ui.py | 6 +++++- 5 files changed, 17 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 85be8fc..fe36166 100644 --- a/README.md +++ b/README.md @@ -117,17 +117,11 @@ run here by default, on models of your own. The cloud is the other option: speech to text on **OpenAI**, **Groq** or **OpenRouter** (`gpt-4o-transcribe`), 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`. - -All settings, including the indicator screen and corner, are stored outside the -application: in `$XDG_CONFIG_HOME/dikte/config.json` (normally -`~/.config/dikte/config.json`) on Linux, `%APPDATA%\Dikte\config.json` on -Windows, and `~/Library/Application Support/Dikte/config.json` on macOS. The -file is mode 600 where Unix permissions apply. Rebuilding, replacing or -updating Dikte therefore keeps the settings. Only removing that file or -uninstalling with `--purge` resets them. 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. +`GROQ_API_KEY` and `OPENROUTER_API_KEY`, and are stored in +`~/.config/dikte/config.json`, mode 600, or in +`~/Library/Application Support/Dikte` on a Mac. 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. ## Using it diff --git a/README.tr.md b/README.tr.md index 0457c52..5e88c69 100644 --- a/README.tr.md +++ b/README.tr.md @@ -114,15 +114,8 @@ 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. - -Gösterge ekranı ve köşesi dahil bütün ayarlar uygulamanın dışında saklanır: -Linux'ta `$XDG_CONFIG_HOME/dikte/config.json` (normalde -`~/.config/dikte/config.json`), Windows'ta `%APPDATA%\Dikte\config.json`, -macOS'ta `~/Library/Application Support/Dikte/config.json`. Unix izinlerinin -geçerli olduğu sistemlerde dosyanın izni 600'dür. Bu nedenle Dikte'yi yeniden -derlemek, değiştirmek veya güncellemek ayarları silmez. Yalnızca bu dosyayı -silmek ya da kaldırıcıyı `--purge` ile çalıştırmak ayarları sıfırlar. +`OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json` +içinde, izinler 600, Mac'te ise `~/Library/Application Support/Dikte` altında. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin. diff --git a/dikte/i18n.py b/dikte/i18n.py index 56ec263..c4f19d1 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -150,7 +150,7 @@ TR = { # --- settings: tabs and general ------------------------------------ "Dikte Settings": "Dikte Ayarları", "General": "Genel", - "Display": "Görüntü", + "Display": "Ekran", "API and models": "API ve modeller", "Cleanup rules": "Temizleme kuralları", "Audio file": "Ses dosyası", diff --git a/dikte/overlay.py b/dikte/overlay.py index a4acbe3..8fc561a 100644 --- a/dikte/overlay.py +++ b/dikte/overlay.py @@ -246,7 +246,10 @@ class Overlay(QWidget): self.resize(width, HEIGHT) def _reposition(self): - # On a multi-monitor setup, show up where the user actually is. + # The screen the settings name, or, when none is named or it is not + # plugged in right now, where the user actually is. Names are connector + # names on X11 and model names on macOS, where two identical monitors + # can share one; the first then wins. screen = next( (item for item in QApplication.screens() if item.name() == self.screen_name), None, diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index a032ffc..945e710 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -732,10 +732,14 @@ class SettingsWindow(QDialog): self.indicator_screen = QComboBox() self.indicator_screen.addItem(t("Follow the mouse pointer"), "") for screen in QGuiApplication.screens(): + # The native resolution, so that a scaled 4K screen reads + # 3840 × 2160 and not the 1920 × 1080 Qt sees through the scale. area = screen.geometry() + ratio = screen.devicePixelRatio() self.indicator_screen.addItem( t("{name} ({width} × {height})", name=screen.name(), - width=area.width(), height=area.height()), + width=round(area.width() * ratio), + height=round(area.height() * ratio)), screen.name(), ) form.addRow(t("Indicator screen"), self.indicator_screen) From d331ccb8779e8bc873d5ce3ea28def0550034d46 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 27 Aug 2026 16:12:18 +0300 Subject: [PATCH 6/6] Fetch every model list at open, without being asked Codex's list already arrived on its own, because reading a cache on disk costs nothing. The other three lists were behind a Fetch button, which meant the built-in ones aged in front of anyone who never pressed it. Now OpenRouter's and Google's lists are fetched when the settings window opens, and Antigravity's comes off `agy models`, which prints one id-tab-name line per model and answers over the network in a couple of seconds; all three run off the interface thread. Nobody asked, so nothing is reported: a failure changes nothing on screen, the built-in lists stay, and the Fetch buttons remain both the retry and the place an error is worth explaining. A provider whose key has not been given is not called at all, so opening Settings is not by itself a request to two vendors. Also drops an import the merge had left in twice. Co-Authored-By: Claude Fable 5 --- dikte/assistant.py | 26 +++++++++++++++ dikte/settings_ui.py | 74 ++++++++++++++++++++++++++++++++++++++++- tests/test_assistant.py | 40 ++++++++++++++++++++++ tests/test_ui.py | 66 +++++++++++++++++++++++++++++++++++- 4 files changed, 204 insertions(+), 2 deletions(-) diff --git a/dikte/assistant.py b/dikte/assistant.py index 7310167..13d564e 100644 --- a/dikte/assistant.py +++ b/dikte/assistant.py @@ -487,6 +487,32 @@ def _agy_label(step): return t("Using {name}…", name=name or "a tool") +def agy_models(): + """The models Antigravity itself would offer right now, in its own order. + + `agy models` prints one `iddisplay name` line per model, so the list + is as current as the account behind the CLI. Unlike Codex it asks Google + rather than a cache on disk, a couple of seconds the caller spends off the + interface thread. A machine without agy, or a call that fails, answers + with nothing and the caller keeps its built-in list. + """ + if not shutil.which("agy"): + return [] + try: + proc = subprocess.run(["agy", "models"], + capture_output=True, text=True, timeout=30) + except (OSError, subprocess.SubprocessError): + return [] + if proc.returncode != 0: + return [] + ids = [] + for line in (proc.stdout or "").splitlines(): + model_id, tab, _ = line.partition("\t") + if tab and model_id.strip(): + ids.append(model_id.strip()) + return ids + + # --- OpenRouter ----------------------------------------------------------- def _ask_openrouter(prompt, conf, on_stage): diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index b84b65a..61faa19 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -31,7 +31,6 @@ from . import meeting from . import paste from . import update from .filetranscribe import FileTranscriber -from . import i18n from .i18n import t UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")] @@ -575,6 +574,9 @@ class SettingsWindow(QDialog): _gemini_models_loaded = pyqtSignal(list, str) _transcribe_models_loaded = pyqtSignal(list, str) _codex_models_loaded = pyqtSignal(list) + _agy_models_loaded = pyqtSignal(list) + # Which hosted provider's list arrived on its own at open, and the list. + _hosted_models_loaded = pyqtSignal(str, list) # Which key was tested, whether it worked, and what to write under it. _test_done = pyqtSignal(str, bool, str) # The release that was found, or None, and what went wrong instead. @@ -635,6 +637,8 @@ class SettingsWindow(QDialog): self._gemini_models_loaded.connect(self._on_gemini_models_loaded) self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded) self._codex_models_loaded.connect(self._on_codex_models_loaded) + self._agy_models_loaded.connect(self._on_agy_models_loaded) + self._hosted_models_loaded.connect(self._on_hosted_models_loaded) self._test_done.connect(self._on_test_done) self._update_checked.connect(self._on_update_checked) self.transcriber.progress.connect(self._on_file_progress) @@ -646,6 +650,8 @@ class SettingsWindow(QDialog): self.meetings.failed.connect(self._on_minutes_failed) self._load() self._load_codex_models() + self._load_agy_models() + self._load_hosted_models() # Connected after the load, so that filling the boxes in is not taken # for the user ticking them. self.file_timestamps.toggled.connect(self._remember_file_choices) @@ -2096,6 +2102,72 @@ class SettingsWindow(QDialog): combo.addItem(name, name) combo.setCurrentText(current) + def _load_agy_models(self): + """Ask Antigravity which models it offers, off the interface thread. + + The same arrangement as Codex, except agy answers over the network + rather than from a cache, so the couple of seconds it takes are spent + where nobody is waiting. Skipped when agy is not installed, which is + also when the built-in list stays on screen and nobody is running + Antigravity anyway. + """ + if not shutil.which("agy"): + return + + def work(): + found = assistant.agy_models() + if found: + self._agy_models_loaded.emit(found) + + threading.Thread(target=work, daemon=True).start() + + def _on_agy_models_loaded(self, models): + for combo in (self.cleanup_agy_model, self.assistant_agy_model): + current = combo.currentText() + combo.clear() + combo.addItem(t("Antigravity's own default"), "") + for name in models: + combo.addItem(name, name) + combo.setCurrentText(current) + + def _load_hosted_models(self): + """Fetch OpenRouter's and Google's lists at open, without being asked. + + The Fetch buttons stay: they are the retry, and the place a failure is + worth explaining. Here nobody asked, so an error changes nothing on + screen and the built-in lists remain, and a provider whose key has not + been given yet is not called at all. + """ + jobs = [] + openrouter_key = self.conf.openrouter_key() + if openrouter_key: + jobs.append(("openrouter", + lambda: api.openrouter_models(openrouter_key))) + gemini_key = self.conf.gemini_key() + gemini_base = self.conf["gemini_base_url"] + if gemini_key: + jobs.append(("gemini", + lambda: api.gemini_models(gemini_key, gemini_base))) + for provider, fetch in jobs: + def work(provider=provider, fetch=fetch): + try: + found = fetch() + except api.ApiError: + return + if found: + self._hosted_models_loaded.emit(provider, found) + + threading.Thread(target=work, daemon=True).start() + + def _on_hosted_models_loaded(self, provider, models): + combos = ((self.cleanup_model, self.meeting_model) + if provider == "openrouter" else (self.cleanup_gemini_model,)) + for combo in combos: + current = combo.currentText() + combo.clear() + combo.addItems(models) + combo.setCurrentText(current) + def _test_openai(self): key, base = self._typed_key("openai") self._test_key("openai", lambda: t( diff --git a/tests/test_assistant.py b/tests/test_assistant.py index 634c274..abc79bb 100644 --- a/tests/test_assistant.py +++ b/tests/test_assistant.py @@ -846,5 +846,45 @@ class CodexModels(DikteTest): self.assertEqual(assistant.codex_models(), []) +class AgyModels(DikteTest): + """The model list read off `agy models`: one id, a tab, a display name.""" + + LISTING = ("gemini-4-flash-high\tGemini 4 Flash (High)\n" + "gemini-4-flash-low\tGemini 4 Flash (Low)\n" + "a line with no tab is not a model\n" + "\ta tab with no id in front of it is not one either\n") + + def models(self, reply, code=0): + with only_these_tools("agy"), \ + mock.patch.object(subprocess, "run", + return_value=FakeCompleted( + returncode=code, stdout=reply)) as run: + found = assistant.agy_models() + self.run_call = run + return found + + def test_the_listing_arrives_in_agy_s_own_order(self): + found = self.models(self.LISTING) + self.assertEqual(found, ["gemini-4-flash-high", "gemini-4-flash-low"]) + self.assertEqual(self.run_call.call_args.args[0], ["agy", "models"]) + + def test_an_agy_that_is_not_installed_is_not_run(self): + with only_these_tools(), \ + mock.patch.object(subprocess, "run") as run: + self.assertEqual(assistant.agy_models(), []) + run.assert_not_called() + + def test_a_call_that_failed_answers_with_nothing(self): + self.assertEqual(self.models("error: not logged in", code=1), []) + self.assertEqual(self.models(""), []) + + def test_an_agy_that_hangs_is_given_up_on(self): + with only_these_tools("agy"), \ + mock.patch.object(subprocess, "run", + side_effect=subprocess.TimeoutExpired( + ["agy"], 30)): + self.assertEqual(assistant.agy_models(), []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index 2c9435f..caa4317 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -28,6 +28,11 @@ from dikte import settings_ui from dikte import update from tests.support import DikteTest, only_these_tools +# The harness below replaces this method on the class so that opening a window +# in a test never calls anybody; taken here, before any test runs, so the two +# tests about what it does when called still have the real one. +REAL_LOAD_HOSTED_MODELS = settings_ui.SettingsWindow._load_hosted_models + # One application for the whole run; Qt allows no second one. _app = QApplication.instance() or QApplication([]) @@ -139,6 +144,10 @@ class Settings(DikteTest): "_load_transcribe_models")) self.enterContext(mock.patch.object(settings_ui.SettingsWindow, "_load_codex_models")) + self.enterContext(mock.patch.object(settings_ui.SettingsWindow, + "_load_agy_models")) + self.enterContext(mock.patch.object(settings_ui.SettingsWindow, + "_load_hosted_models")) # The local model boxes fetch their own list the moment they are shown, # from a thread, which is nobody's test failing but a real request. self.enterContext(mock.patch.object(settings_ui.LocalModelBox, @@ -288,6 +297,53 @@ class Settings(DikteTest): self.assertEqual(window.cleanup_codex_model.currentText(), "my-own-model") + def test_agy_answering_refills_both_of_its_boxes(self): + """The same arrangement as Codex: both boxes, nothing chosen is lost.""" + conf = self.config(cleanup_agy_model="my-own-model") + window = self.window(conf) + window._on_agy_models_loaded(["gemini-4-flash-low", "gemini-4-pro-low"]) + for combo in (window.cleanup_agy_model, window.assistant_agy_model): + with self.subTest(combo=combo.objectName() or "combo"): + offered = [combo.itemText(i) for i in range(combo.count())] + self.assertEqual(offered[1:], + ["gemini-4-flash-low", "gemini-4-pro-low"]) + self.assertEqual(window.cleanup_agy_model.currentText(), "my-own-model") + + def test_openrouter_s_list_arriving_at_open_refills_cleanup_and_meetings(self): + conf = self.config(cleanup_model="my/own-model") + window = self.window(conf) + window._on_hosted_models_loaded("openrouter", ["a/one", "b/two"]) + for combo in (window.cleanup_model, window.meeting_model): + with self.subTest(combo=combo.objectName() or "combo"): + offered = [combo.itemText(i) for i in range(combo.count())] + self.assertEqual(offered, ["a/one", "b/two"]) + self.assertEqual(window.cleanup_model.currentText(), "my/own-model") + + def test_google_s_list_arriving_at_open_refills_its_own_box_only(self): + window = self.window(self.config(cleanup_gemini_model="gemini-x")) + before = window.cleanup_model.count() + window._on_hosted_models_loaded("gemini", ["gemini-4-flash"]) + offered = [window.cleanup_gemini_model.itemText(i) + for i in range(window.cleanup_gemini_model.count())] + self.assertEqual(offered, ["gemini-4-flash"]) + self.assertEqual(window.cleanup_gemini_model.currentText(), "gemini-x") + self.assertEqual(window.cleanup_model.count(), before) + + def test_no_key_no_call_home_at_open(self): + """Opening Settings is not consent to be talked about to two vendors.""" + window = self.window(self.config()) + with mock.patch.dict(os.environ, {}, clear=True), \ + mock.patch.object(settings_ui.threading, "Thread") as thread: + REAL_LOAD_HOSTED_MODELS(window) + thread.assert_not_called() + + def test_a_key_on_file_is_fetched_with_at_open(self): + window = self.window(self.config(openrouter_api_key="sk-or-x", + gemini_api_key="AIza-x")) + with mock.patch.object(settings_ui.threading, "Thread") as thread: + REAL_LOAD_HOSTED_MODELS(window) + self.assertEqual(thread.call_count, 2) + def test_the_update_line_names_the_version_that_is_running(self): window = self.window(cfg.Config()) self.assertIn(settings_ui.__version__, window.update_status.text()) @@ -1005,7 +1061,11 @@ class MeetingSources(DikteTest): mock.patch.object(settings_ui.SettingsWindow, "_load_transcribe_models"), \ mock.patch.object(settings_ui.SettingsWindow, - "_load_codex_models"): + "_load_codex_models"), \ + mock.patch.object(settings_ui.SettingsWindow, + "_load_agy_models"), \ + mock.patch.object(settings_ui.SettingsWindow, + "_load_hosted_models"): window = settings_ui.SettingsWindow(cfg.Config()) self.addCleanup(window.deleteLater) self.addCleanup(window.close) @@ -1037,6 +1097,10 @@ class LocalModels(DikteTest): # And one with Codex on it would ask it for its model list. self.enterContext(mock.patch.object(settings_ui.SettingsWindow, "_load_codex_models")) + self.enterContext(mock.patch.object(settings_ui.SettingsWindow, + "_load_agy_models")) + self.enterContext(mock.patch.object(settings_ui.SettingsWindow, + "_load_hosted_models")) def window(self, conf): window = settings_ui.SettingsWindow(conf)