Let a dictation be a command for Claude Code, not only text

Dictation ends the same way every time: the words you said land in the window
you were in. But half of what you say to a machine is not text to place, it is
something to do, and the answer to "what is on my calendar today" is not that
sentence written out.

`claude -p` is that session run without a window: the same skills, the same
connected services, the same account. So a second shortcut records exactly as
the first one does and sends the transcript there instead, and what comes back
is pasted where the transcript would have been. Which is what puts "save that
to my calendar on Thursday at three" inside a text field that has nothing to do
with Claude.

Permissions are the part worth being deliberate about, because nothing here can
answer a prompt: a mode that would have asked denies instead. It runs in `auto`,
which decides for itself with the prompt-injection checks left on, and the other
two modes are one setting away. What Claude was not allowed to touch is reported
rather than swallowed, since a reply that reads perfectly normal is otherwise
the only sign that the job did not happen.

Commands close together stay in one conversation, so "and move that to Thursday"
knows what "that" is; half an hour of silence ends it, as does the tray. The
answer is written to be read where it lands: no headings, no lists, and a
sentence naming what was done when something was done.

Its output is read as it streams rather than waited out, so the corner names the
tool it is on. A calendar lookup took 29 s in testing, which a still indicator
would have made indistinguishable from a hang. The clock and the stop button are
watched from a thread of their own, because the stream blocks between lines and
a model that thinks for a minute sends none: they end the run by killing the
process, which closes the stream and unwinds everything else on its own.

Transcript cleanup is off on this path. Claude reads through "erm" and "hani"
without help, and skipping it saves an API call and a second or two in front of
a screen you are standing at.
This commit is contained in:
yusufipk
2026-07-28 16:57:10 +07:00
parent 352f7b6c22
commit 0206303751
10 changed files with 908 additions and 37 deletions
+10
View File
@@ -46,6 +46,7 @@ set next to it.
| --- | --- | | --- | --- |
| Start / stop recording | `Ctrl+Space`, or click the tray icon | | Start / stop recording | `Ctrl+Space`, or click the tray icon |
| Cancel a recording | Tray menu → *Cancel recording*, or `dikte cancel` | | Cancel a recording | Tray menu → *Cancel recording*, or `dikte cancel` |
| Speak a command to Claude | Tray menu → *Ask Claude*, or `dikte ask` |
| Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` | | Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` |
| Settings | Tray menu → *Settings*, or `dikte settings` | | Settings | Tray menu → *Settings*, or `dikte settings` |
| Reload after an update | Tray menu → *Restart*, or `dikte restart` | | Reload after an update | Tray menu → *Restart*, or `dikte restart` |
@@ -81,6 +82,14 @@ elapsed time, then the stage it is on. It never takes focus. Pressing
- **A failed cleanup is never silent.** The raw transcript is still pasted so the - **A failed cleanup is never silent.** The raw transcript is still pasted so the
dictation is not lost, but the indicator turns amber with the reason instead of dictation is not lost, but the indicator turns amber with the reason instead of
looking like a normal run. looking like a normal run.
- **A dictation can be a command instead.** Its own shortcut sends the
transcript to Claude Code (`claude -p`) rather than pasting it, and pastes back
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. Model, permissions and
working directory are under Settings → Claude, and commands close together
stay in one conversation.
- **Meetings** are recorded from the microphone and the speaker output at the - **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 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 instead of guessing at it. The two sides are transcribed separately and
@@ -112,6 +121,7 @@ needs your user in the `input` group: `sudo usermod -aG input $USER`.
dikte.py entry point, tray icon, state machine, IPC dikte.py entry point, tray icon, state machine, IPC
audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting
meeting.py channel split, speaker labelling, cleanup, minutes meeting.py channel split, speaker labelling, cleanup, minutes
assistant.py running a dictation through Claude Code and reading it back
api.py transcription on either provider, OpenRouter cleanup (stdlib only) api.py transcription on either provider, OpenRouter cleanup (stdlib only)
worker.py transcribe → clean up → clipboard → paste worker.py transcribe → clean up → clipboard → paste
vad.py deciding whether a recording holds speech at all vad.py deciding whether a recording holds speech at all
+9
View File
@@ -46,6 +46,7 @@ yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebili
| --- | --- | | --- | --- |
| Kaydı başlat / bitir | `Ctrl+Space`, ya da tepsi simgesine tıkla | | Kaydı başlat / bitir | `Ctrl+Space`, ya da tepsi simgesine tıkla |
| Kaydı iptal et | Tepsi menüsü → *Kaydı iptal et*, ya da `dikte cancel` | | Kaydı iptal et | Tepsi menüsü → *Kaydı iptal et*, ya da `dikte cancel` |
| Claude'a sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` |
| Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` | | Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` |
| Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` | | Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` |
| Güncelleme sonrası yeniden yükle | Tepsi menüsü → *Yeniden başlat*, ya da `dikte restart` | | Güncelleme sonrası yeniden yükle | Tepsi menüsü → *Yeniden başlat*, ya da `dikte restart` |
@@ -80,6 +81,13 @@ süreyi, ardından hangi aşamada olduğunu gösterir. Odak almaz. Dikte çalı
- **Başarısız temizleme sessizce geçmez.** Dikte kaybolmasın diye ham transkript - **Başarısız temizleme sessizce geçmez.** Dikte kaybolmasın diye ham transkript
yine yapıştırılır ama gösterge kehribar rengine döner ve nedenini söyler, yine yapıştırılır ama gösterge kehribar rengine döner ve nedenini söyler,
normal bir çalışma gibi görünmez. normal bir çalışma gibi görünmez.
- **Dikte bunun yerine bir komut da olabilir.** Kendi kısayolu transkripti
yapıştırmak yerine Claude Code'a (`claude -p`) gönderir ve oradan döneni
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. Model, izinler ve çalışma dizini Ayarlar → Claude
sekmesinde; arka arkaya verilen komutlar tek bir konuşmada kalır.
- **Toplantılar** mikrofonla hoparlör çıkışından aynı anda kaydedilir; kimin ne - **Toplantılar** mikrofonla hoparlör çıkışından aynı anda kaydedilir; kimin ne
dediği tahmin edilmez, sesin hangi kanaldan geldiğiyle belli olur. İki taraf dediği tahmin edilmez, sesin hangi kanaldan geldiğiyle belli olur. İki taraf
ayrı ayrı yazıya çevrilip tek bir zaman damgalı transkriptte birleştirilir, ayrı ayrı yazıya çevrilip tek bir zaman damgalı transkriptte birleştirilir,
@@ -110,6 +118,7 @@ grubunda olmasını gerektirir: `sudo usermod -aG input $USER`.
dikte.py giriş noktası, tepsi simgesi, durum makinesi, IPC dikte.py giriş noktası, tepsi simgesi, durum makinesi, IPC
audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg
meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak
assistant.py dikteyi Claude Code'dan geçirip cevabı geri okuma
api.py iki sağlayıcıda transkript + OpenRouter temizleme (yalnız stdlib) api.py iki sağlayıcıda transkript + OpenRouter temizleme (yalnız stdlib)
worker.py transkript → temizleme → pano → yapıştırma worker.py transkript → temizleme → pano → yapıştırma
vad.py kayıtta gerçekten konuşma var mı kararı vad.py kayıtta gerçekten konuşma var mı kararı
+295
View File
@@ -0,0 +1,295 @@
"""Handing a dictation to Claude Code as a command, and pasting back its answer.
`claude -p` runs the same session an interactive window would open: the same
skills, the same MCP servers, the same account. So a dictation does not have to
end as text on the screen. It can be a question to answer or a job to carry out,
and what comes back is pasted exactly where the transcript would have been.
Its output is read as it arrives 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 it picks up is named in the corner
while it works.
"""
import json
import os
import shutil
import subprocess
import threading
import time
import config as cfg
from i18n import t
SESSION_FILE = cfg.DATA_DIR / "assistant.json"
# What to say in the indicator for a tool, keyed by name. Anything unlisted is
# named as it comes, which is better than a generic "working" for the tools that
# arrive from an MCP server nobody wrote this table for.
TOOL_LABELS = {
"Bash": "Running a command…",
"BashOutput": "Running a command…",
"Read": "Reading…",
"Glob": "Looking through files…",
"Grep": "Searching the files…",
"Edit": "Editing a file…",
"Write": "Writing a file…",
"NotebookEdit": "Editing a file…",
"WebSearch": "Searching the web…",
"WebFetch": "Reading a web page…",
"Task": "Handing it to a subagent…",
"TodoWrite": "Planning…",
}
class AssistantError(Exception):
pass
class Cancelled(Exception):
pass
# --- the conversation -----------------------------------------------------
#
# One conversation is kept across dictations, so "and move that to tomorrow"
# means something. It is dropped once it has sat unused for long enough: an
# hour later the next command is almost certainly a new subject, and dragging
# the old one along costs tokens and invites the model to answer the wrong
# question.
def read_session(max_age_seconds):
"""The session to continue, or "" when there is none worth continuing."""
try:
with open(SESSION_FILE, encoding="utf-8") as fh:
row = json.load(fh)
except (OSError, json.JSONDecodeError, ValueError):
return ""
session = str(row.get("session", ""))
if not session:
return ""
if max_age_seconds and time.time() - row.get("ts", 0) > max_age_seconds:
return ""
return session
def write_session(session):
try:
cfg.DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(SESSION_FILE, "w", encoding="utf-8") as fh:
json.dump({"session": session, "ts": time.time()}, fh)
except OSError:
pass
def clear_session():
try:
SESSION_FILE.unlink(missing_ok=True)
except OSError:
pass
def session_age():
"""Seconds since the stored conversation was last used, or None."""
try:
with open(SESSION_FILE, encoding="utf-8") as fh:
row = json.load(fh)
except (OSError, json.JSONDecodeError, ValueError):
return None
return time.time() - row.get("ts", 0) if row.get("session") else None
# --- the call -------------------------------------------------------------
def working_dir(conf):
wanted = conf["assistant_dir"].strip()
if wanted and os.path.isdir(os.path.expanduser(wanted)):
return os.path.expanduser(wanted)
return os.path.expanduser("~")
def ask(prompt, conf, on_stage=None, should_stop=None):
"""Run the prompt through Claude Code. Returns (answer, warning).
`warning` is set when the answer arrived but something about the run should
be seen anyway, a denied tool above all: the reply still reads like a normal
one, and only the denial explains why it did not do what it was asked to.
"""
if not shutil.which("claude"):
raise AssistantError(t(
"claude not found. Install Claude Code and make sure `claude` is on "
"your PATH."
))
session = read_session(conf["assistant_session_minutes"] * 60)
try:
return _run(prompt, conf, session, on_stage, should_stop)
except _SessionGone:
# The conversation it pointed at is not there any more: the history was
# cleared, or it was started somewhere else. Say nothing and start over,
# because from the outside this is just the first command of the day.
clear_session()
return _run(prompt, conf, "", on_stage, should_stop)
class _SessionGone(Exception):
pass
def _run(prompt, conf, session, on_stage, should_stop):
cmd = [
"claude", "-p", prompt,
"--output-format", "stream-json", "--verbose",
"--model", conf["assistant_model"],
"--permission-mode", conf["assistant_permission_mode"],
"--append-system-prompt", conf.assistant_prompt(),
]
if session:
cmd += ["--resume", session]
try:
proc = subprocess.Popen(
cmd, cwd=working_dir(conf), stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace", bufsize=1,
)
except OSError as exc:
raise AssistantError(t("Could not run claude: {error}", error=exc)) from exc
answer, warning, new_session, failure = "", "", "", ""
# Reading the stream blocks between lines, and a model that thinks for a
# minute sends none. So the clock and the stop button are watched from the
# side, and they end the run by killing the process: that closes the stream
# and the loop below falls out of its own accord.
ended = {"cancelled": False, "timed_out": False}
watchdog = threading.Thread(
target=_watch,
args=(proc, time.monotonic() + conf["assistant_timeout"], should_stop, ended),
daemon=True,
)
watchdog.start()
try:
for line in proc.stdout:
try:
event = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
kind = event.get("type")
if kind == "system" and event.get("subtype") == "init":
new_session = event.get("session_id", "") or new_session
elif kind == "assistant" and on_stage:
for label in _labels(event):
on_stage(label)
elif kind == "result":
new_session = event.get("session_id", "") or new_session
answer = (event.get("result") or "").strip()
if event.get("is_error"):
failure = answer or t("Claude ended with an error.")
answer = ""
warning = _denial_warning(event)
finally:
stderr = _finish(proc)
watchdog.join(timeout=1)
if ended["cancelled"]:
raise Cancelled()
if ended["timed_out"]:
raise AssistantError(t(
"Claude did not finish within {seconds} seconds.",
seconds=conf["assistant_timeout"],
))
if proc.returncode != 0 and not answer:
if session and _session_missing(stderr):
raise _SessionGone()
raise AssistantError(_first_line(stderr) or failure or t(
"claude exited with code {code}.", code=proc.returncode
))
if failure:
raise AssistantError(failure)
if not answer:
raise AssistantError(t("Claude answered with nothing."))
if new_session:
write_session(new_session)
return answer, warning
def _watch(proc, deadline, should_stop, ended):
while proc.poll() is None:
if should_stop is not None and should_stop():
ended["cancelled"] = True
break
if time.monotonic() > deadline:
ended["timed_out"] = True
break
time.sleep(0.25)
if ended["cancelled"] or ended["timed_out"]:
_kill(proc)
def _labels(event):
"""The indicator lines for one assistant message, in the order they happen."""
out = []
for block in event.get("message", {}).get("content", []) or []:
if not isinstance(block, dict) or block.get("type") != "tool_use":
continue
name = block.get("name", "")
if name in TOOL_LABELS:
out.append(t(TOOL_LABELS[name]))
elif name == "Skill":
skill = (block.get("input") or {}).get("skill", "")
out.append(t("Using {name}", name=skill or "a skill"))
elif name.startswith("mcp__"):
parts = name.split("__")
out.append(t("Using {name}", name=parts[1] if len(parts) > 1 else name))
elif name:
out.append(t("Using {name}", name=name))
return out
def _denial_warning(event):
denials = event.get("permission_denials") or []
if not denials:
return ""
names = []
for denial in denials:
name = denial.get("tool_name") if isinstance(denial, dict) else str(denial)
if name and name not in names:
names.append(name)
return t("Claude was not allowed to use: {tools}", tools=", ".join(names))
def _session_missing(stderr):
lowered = stderr.lower()
return "session" in lowered and ("not found" in lowered or "no conversation" in lowered)
def _kill(proc):
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
def _finish(proc):
try:
stderr = proc.stderr.read() or ""
except (OSError, ValueError):
stderr = ""
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
for stream in (proc.stdout, proc.stderr):
try:
stream.close()
except OSError:
pass
return stderr
def _first_line(text):
lines = [line for line in (text or "").splitlines() if line.strip()]
return lines[-1].strip() if lines else ""
+62
View File
@@ -220,6 +220,50 @@ PARTICIPANTS_RULE_TR = ("\n\nTOPLANTIDAKİ KİŞİLER\n{participants}\n"
"yazımla kullan; yine de bir satırı ancak transkript açık " "yazımla kullan; yine de bir satırı ancak transkript açık "
"ediyorsa bunlardan birine bağla.") "ediyorsa bunlardan birine bağla.")
ASSISTANT_PROMPT_EN = """This request reached you from Dikte, a dictation tool.
What you are reading was spoken out loud and turned into text by a speech model,
so a word here and there may have come through wrong. Read it for what was
meant, not for what it says letter by letter.
Your answer is copied to the clipboard and pasted into whatever window the user
was in. It is read where it lands: there is nothing to click, no thread to
follow, and no way to answer a question you ask back.
- Reply in the language you were spoken to in
- Keep it short. A sentence or two when that covers it. No preamble, no "here
is what I found", no closing offer of further help
- Plain prose. No headings, no bullet lists, no bold, and no code fence unless
what was asked for is code
- When you did something rather than answered something, say what you did in
one sentence, carrying the detail that confirms it: the day and time an event
was saved for, the name of a file that was written
- When the request cannot be carried out, say so in one sentence and stop. Do
not guess at what was meant, and do not do something adjacent instead
- If the request is ambiguous in a way that changes the answer, give the answer
under the likelier reading and name the assumption in a clause"""
ASSISTANT_PROMPT_TR = """Bu istek sana Dikte adlı bir dikte uygulamasından geldi.
Okuduğun metin sesli olarak söylendi ve bir konuşma modeli tarafından yazıya
çevrildi; yer yer bir kelime yanlış geçmiş olabilir. Harfi harfine ne yazdığına
değil, ne denmek istendiğine bak.
Cevabın panoya kopyalanıp kullanıcının o an açık olan penceresine yapıştırılıyor.
Cevap düştüğü yerde okunuyor: tıklanacak bir şey, takip edilecek bir konuşma ya
da senin soracağın soruya verilecek bir yanıt yok.
- Sana hangi dilde konuşulduysa o dilde cevap ver
- Kısa tut. Yetiyorsa bir iki cümle. Giriş cümlesi kurma, "işte buldukların"
deme, sonunda başka yardım teklif etme
- Düz metin yaz. Başlık, madde işareti, kalın yazı kullanma; istenen şey kodun
kendisi değilse kod bloğu da açma
- Bir şeyi cevaplamak yerine yaptıysan, ne yaptığını tek cümleyle söyle ve onu
doğrulayan ayrıntıyı da yaz: kaydın hangi güne ve saate düştüğü, yazdığın
dosyanın adı
- İstenen şey yapılamıyorsa tek cümleyle söyle ve dur. Ne denmek istendiğini
tahmin etmeye çalışma, yerine yakın bir şey yapma
- İstek cevabı değiştirecek biçimde belirsizse, daha olası okumaya göre cevapla
ve varsayımını bir yan cümlede söyle"""
DEFAULTS = { DEFAULTS = {
"ui_language": "auto", # auto | tr | en "ui_language": "auto", # auto | tr | en
"openai_api_key": "", "openai_api_key": "",
@@ -268,6 +312,17 @@ DEFAULTS = {
"meeting_participants": "", "meeting_participants": "",
"meeting_keep_audio": False, # a failed run keeps its audio regardless "meeting_keep_audio": False, # a failed run keeps its audio regardless
"meeting_shortcut": "", # empty -> tray only "meeting_shortcut": "", # empty -> tray only
# --- asking Claude Code -----------------------------------------------
"assistant_shortcut": "", # empty -> tray only
"assistant_model": "sonnet", # an alias, or a full model id
"assistant_permission_mode": "auto",
"assistant_dir": "", # empty -> the home directory
"assistant_prompt": "", # empty -> language-specific default
"assistant_cleanup": False, # the model reads through filler words fine
"assistant_paste": True, # paste the answer, not just copy it
"assistant_session_minutes": 30, # 0 -> every command starts fresh
"assistant_timeout": 240,
} }
# Saving the settings window used to write the whole default prompt into the # Saving the settings window used to write the whole default prompt into the
@@ -362,6 +417,9 @@ class Config:
prompt += SPEAKER_RULE_TR if turkish else SPEAKER_RULE_EN prompt += SPEAKER_RULE_TR if turkish else SPEAKER_RULE_EN
return prompt return prompt
def assistant_prompt(self):
return self["assistant_prompt"].strip() or default_assistant_prompt()
# ---- meetings -------------------------------------------------------- # ---- meetings --------------------------------------------------------
def participants(self): def participants(self):
@@ -411,6 +469,10 @@ def default_meeting_prompt():
return MEETING_PROMPT_TR if i18n.language() == "tr" else MEETING_PROMPT_EN return MEETING_PROMPT_TR if i18n.language() == "tr" else MEETING_PROMPT_EN
def default_assistant_prompt():
return ASSISTANT_PROMPT_TR if i18n.language() == "tr" else ASSISTANT_PROMPT_EN
def append_history(entry): def append_history(entry):
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(HISTORY_FILE, "a", encoding="utf-8") as fh: with open(HISTORY_FILE, "a", encoding="utf-8") as fh:
+124 -21
View File
@@ -5,6 +5,8 @@ Usage:
dikte.py run in the background (tray icon) dikte.py run in the background (tray icon)
dikte.py toggle start / stop recording dikte.py toggle start / stop recording
dikte.py cancel discard the current recording dikte.py cancel discard the current recording
dikte.py ask start / stop recording a command for Claude Code
dikte.py ask-reset forget the conversation Claude has been following
dikte.py meeting start / end a meeting recording dikte.py meeting start / end a meeting recording
dikte.py meeting-cancel discard the meeting being recorded dikte.py meeting-cancel discard the meeting being recorded
dikte.py settings open the settings window dikte.py settings open the settings window
@@ -25,6 +27,7 @@ from PyQt6.QtGui import QAction, QIcon # noqa: E402
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402 from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402 from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
import assistant # noqa: E402
import audio # noqa: E402 import audio # noqa: E402
import config as cfg # noqa: E402 import config as cfg # noqa: E402
import hotkey # noqa: E402 import hotkey # noqa: E402
@@ -57,6 +60,10 @@ class Dikte:
self.app = app self.app = app
self.conf = cfg.Config() self.conf = cfg.Config()
self.state = IDLE self.state = IDLE
# Dictation and a command for Claude share the recorder and the state
# machine; which of the two is being recorded is decided when the
# recording starts, and holds until it is finished with.
self.ask_mode = False
self.meeting_state = M_IDLE self.meeting_state = M_IDLE
self.meeting_base = "" self.meeting_base = ""
self.meeting_message = "" self.meeting_message = ""
@@ -76,6 +83,7 @@ class Dikte:
self.pipeline.stage.connect(self.overlay.show_busy) self.pipeline.stage.connect(self.overlay.show_busy)
self.pipeline.finished.connect(self._on_finished) self.pipeline.finished.connect(self._on_finished)
self.pipeline.failed.connect(self._on_error) self.pipeline.failed.connect(self._on_error)
self.pipeline.cancelled.connect(self._on_cancelled)
self.meeting_recorder.levels.connect(self._on_meeting_levels) self.meeting_recorder.levels.connect(self._on_meeting_levels)
self.meeting_recorder.stopped.connect(self._on_meeting_recorded) self.meeting_recorder.stopped.connect(self._on_meeting_recorded)
self.meeting_recorder.died.connect(self._on_meeting_died) self.meeting_recorder.died.connect(self._on_meeting_died)
@@ -111,6 +119,14 @@ class Dikte:
self.toggle_action.triggered.connect(self._toggle) self.toggle_action.triggered.connect(self._toggle)
self.menu.addAction(self.toggle_action) self.menu.addAction(self.toggle_action)
self.ask_action = QAction(t("Ask Claude"), self.menu)
self.ask_action.triggered.connect(self._toggle_ask)
self.menu.addAction(self.ask_action)
self.reset_action = QAction(t("Start a new conversation"), self.menu)
self.reset_action.triggered.connect(self.reset_conversation)
self.menu.addAction(self.reset_action)
self.cancel_action = QAction(t("Cancel recording"), self.menu) self.cancel_action = QAction(t("Cancel recording"), self.menu)
self.cancel_action.triggered.connect(self.cancel) self.cancel_action.triggered.connect(self.cancel)
self.cancel_action.setEnabled(False) self.cancel_action.setEnabled(False)
@@ -159,6 +175,8 @@ class Dikte:
def _set_state(self, state): def _set_state(self, state):
self.state = state self.state = state
if state == IDLE:
self.ask_mode = False
self._refresh_tray() self._refresh_tray()
def _set_meeting_state(self, state): def _set_meeting_state(self, state):
@@ -174,9 +192,33 @@ class Dikte:
BUSY: ("Working…", "view-refresh", "Dikte: working"), BUSY: ("Working…", "view-refresh", "Dikte: working"),
} }
label, icon, tip = labels[self.state] label, icon, tip = labels[self.state]
if self.ask_mode:
tip = "Dikte: talking to Claude"
if self.state == RECORDING:
label, tip = "Start recording", "Dikte: recording for Claude"
self.toggle_action.setText(t(label)) self.toggle_action.setText(t(label))
self.toggle_action.setEnabled(self.state != BUSY) # Each of the two owns its own entry, so the one that is not recording
self.cancel_action.setEnabled(self.state == RECORDING) # goes grey rather than offering to end a recording it did not start.
self.toggle_action.setEnabled(
self.state == IDLE or (self.state == RECORDING and not self.ask_mode)
)
self.ask_action.setText(
t("Stop and ask Claude") if self.state == RECORDING and self.ask_mode
else t("Ask Claude")
)
self.ask_action.setEnabled(
self.state == IDLE or (self.state == RECORDING and self.ask_mode)
)
self.reset_action.setEnabled(self.state != BUSY)
# A Claude command is the one job long enough to be worth calling off
# once it is already running.
self.cancel_action.setText(
t("Stop Claude") if self.state == BUSY and self.ask_mode
else t("Cancel recording")
)
self.cancel_action.setEnabled(
self.state == RECORDING or (self.state == BUSY and self.ask_mode)
)
meeting_labels = { meeting_labels = {
M_IDLE: "Record a meeting", M_IDLE: "Record a meeting",
@@ -207,6 +249,9 @@ class Dikte:
"""A toggle from outside this process: the KDE shortcut, or the CLI.""" """A toggle from outside this process: the KDE shortcut, or the CLI."""
self._external("toggle", self._toggle) self._external("toggle", self._toggle)
def toggle_ask(self):
self._external("ask", self._toggle_ask)
def toggle_meeting(self): def toggle_meeting(self):
self._external("meeting", self._toggle_meeting) self._external("meeting", self._toggle_meeting)
@@ -227,7 +272,8 @@ class Dikte:
if timer is None: if timer is None:
timer = self.last_evdev[name] = QElapsedTimer() timer = self.last_evdev[name] = QElapsedTimer()
timer.restart() timer.restart()
(self._toggle_meeting if name == "meeting" else self._toggle)() handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask}
handlers.get(name, self._toggle)()
def _retire_listener(self): def _retire_listener(self):
self.evdev.stop() self.evdev.stop()
@@ -243,25 +289,41 @@ class Dikte:
def _toggle(self): def _toggle(self):
# Two /dev/input nodes can carry the same keyboard, and a menu click can # Two /dev/input nodes can carry the same keyboard, and a menu click can
# land on top of a key press; swallow the immediate repeat. # land on top of a key press; swallow the immediate repeat.
if self.last_toggle.isValid() and self.last_toggle.elapsed() < 400: if self._repeated():
return return
self.last_toggle.restart()
if self.state == IDLE: if self.state == IDLE:
self.start() self.start()
elif self.state == RECORDING: elif self.state == RECORDING and not self.ask_mode:
self.stop() self.stop()
# requests during BUSY are ignored # requests during BUSY, or for the other mode's recording, are ignored
def start(self): def _toggle_ask(self):
if self._repeated():
return
if self.state == IDLE:
self.start(ask=True)
elif self.state == RECORDING and self.ask_mode:
self.stop()
def _repeated(self):
if self.last_toggle.isValid() and self.last_toggle.elapsed() < 400:
return True
self.last_toggle.restart()
return False
def start(self, ask=False):
if self.state != IDLE: if self.state != IDLE:
return return
self.overlay.show_recording() self.ask_mode = ask
self.overlay.show_recording(asking=ask)
self.elapsed.restart() self.elapsed.restart()
self.ticker.start() self.ticker.start()
self._set_state(RECORDING) self._set_state(RECORDING)
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"]) self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
def start_ask(self):
self.start(ask=True)
def stop(self): def stop(self):
if self.state != RECORDING: if self.state != RECORDING:
return return
@@ -271,6 +333,13 @@ class Dikte:
self.recorder.stop() self.recorder.stop()
def cancel(self): def cancel(self):
if self.state == BUSY:
# Only a Claude command can be called off once it is under way, and
# it says so itself when it lets go.
if self.ask_mode:
self.overlay.show_busy(t("Stopping…"))
self.pipeline.cancel()
return
if self.state != RECORDING: if self.state != RECORDING:
return return
self.ticker.stop() self.ticker.stop()
@@ -278,6 +347,12 @@ class Dikte:
self.overlay.dismiss() self.overlay.dismiss()
self._set_state(IDLE) self._set_state(IDLE)
def reset_conversation(self):
"""Drop the thread Claude has been following, so the next command starts
a conversation of its own."""
assistant.clear_session()
self.overlay.show_done(t("Claude starts fresh next time."), 2500)
def _tick(self): def _tick(self):
seconds = self.elapsed.elapsed() / 1000.0 seconds = self.elapsed.elapsed() / 1000.0
self.overlay.set_seconds(seconds) self.overlay.set_seconds(seconds)
@@ -414,24 +489,41 @@ class Dikte:
self.stop_meeting() self.stop_meeting()
def _on_recorded(self, wav_path, duration, rms_values): def _on_recorded(self, wav_path, duration, rms_values):
self.pipeline.run(wav_path, duration, rms_values) self.pipeline.run(wav_path, duration, rms_values, ask=self.ask_mode)
def _on_finished(self, _raw, text, warning): def _on_finished(self, _raw, text, warning):
asked = self.ask_mode
if warning: if warning:
# The text was still pasted, but cleanup did not run. Say so loudly: # The text was still pasted, but something on the way did not run.
# a rejected key otherwise looks exactly like working dictation. # Say so loudly: a rejected key, or a tool Claude was not allowed to
# touch, otherwise looks exactly like a job that worked.
first_line = warning.splitlines()[0]
self.overlay.show_warning( self.overlay.show_warning(
t("Pasted raw, cleanup failed: {error}", error=warning.splitlines()[0]) t("Claude answered, but: {error}", error=first_line) if asked
else t("Pasted raw, cleanup failed: {error}", error=first_line)
) )
self.tray.showMessage( self.tray.showMessage(
t("Dikte: cleanup failed"), warning, t("Dikte: Claude could not do all of it") if asked
else t("Dikte: cleanup failed"),
f"{warning}\n\n{text}" if asked else warning,
QSystemTrayIcon.MessageIcon.Warning, 10000, QSystemTrayIcon.MessageIcon.Warning, 10000,
) )
else: else:
preview = text.replace("\n", " ") preview = text.replace("\n", " ")
preview = preview[:48] + ("" if len(preview) > 48 else "") preview = preview[:48] + ("" if len(preview) > 48 else "")
action = t("Pasted") if self.conf["auto_paste"] else t("Copied") if asked:
self.overlay.show_done(t("{action}: {preview}", action=action, preview=preview)) # Longer than a dictation's flash: this one is an answer, and
# it is worth being able to read the start of it in the corner.
self.overlay.show_done(t("Claude: {preview}", preview=preview), 6000)
else:
action = t("Pasted") if self.conf["auto_paste"] else t("Copied")
self.overlay.show_done(
t("{action}: {preview}", action=action, preview=preview)
)
self._set_state(IDLE)
def _on_cancelled(self):
self.overlay.show_done(t("Stopped."), 2000)
self._set_state(IDLE) self._set_state(IDLE)
def _on_error(self, message): def _on_error(self, message):
@@ -448,7 +540,8 @@ class Dikte:
def open_settings(self): def open_settings(self):
if self.settings_window is None: if self.settings_window is None:
self.settings_window = SettingsWindow( self.settings_window = SettingsWindow(
self.conf, launch_command(), meeting_command(), self.meetings self.conf, launch_command(), meeting_command(), self.meetings,
ask_command(),
) )
self.settings_window.applied.connect(self._apply_settings) self.settings_window.applied.connect(self._apply_settings)
self.settings_window.finished.connect(self._settings_closed) self.settings_window.finished.connect(self._settings_closed)
@@ -466,6 +559,7 @@ class Dikte:
self._refresh_tray() self._refresh_tray()
if self.conf["evdev_hotkey"]: if self.conf["evdev_hotkey"]:
self.evdev.start({"toggle": self.conf["shortcut"], self.evdev.start({"toggle": self.conf["shortcut"],
"ask": self.conf["assistant_shortcut"],
"meeting": self.conf["meeting_shortcut"]}) "meeting": self.conf["meeting_shortcut"]})
else: else:
self.evdev.stop() self.evdev.stop()
@@ -509,6 +603,10 @@ def meeting_command():
return f"{sys.executable} {os.path.realpath(__file__)} meeting" return f"{sys.executable} {os.path.realpath(__file__)} meeting"
def ask_command():
return f"{sys.executable} {os.path.realpath(__file__)} ask"
def send_command(command, timeout=800): def send_command(command, timeout=800):
"""Hand a command to the running instance; False when there is none.""" """Hand a command to the running instance; False when there is none."""
socket = QLocalSocket() socket = QLocalSocket()
@@ -527,8 +625,8 @@ def main():
command = args[0] if args else "" command = args[0] if args else ""
if command and command not in ("toggle", "cancel", "settings", "restart", if command and command not in ("toggle", "cancel", "settings", "restart",
"quit", "start", "stop", "meeting", "quit", "start", "stop", "ask", "ask-reset",
"meeting-cancel"): "meeting", "meeting-cancel"):
print(__doc__) print(__doc__)
return 2 return 2
@@ -541,7 +639,8 @@ def main():
if send_command(command or "settings"): if send_command(command or "settings"):
return 0 return 0
if command in ("cancel", "quit", "stop", "restart", "meeting-cancel"): if command in ("cancel", "quit", "stop", "restart", "meeting-cancel",
"ask-reset"):
return 0 return 0
if not QSystemTrayIcon.isSystemTrayAvailable(): if not QSystemTrayIcon.isSystemTrayAvailable():
@@ -569,6 +668,8 @@ def main():
"start": dikte.start, "start": dikte.start,
"stop": dikte.stop, "stop": dikte.stop,
"cancel": dikte.cancel, "cancel": dikte.cancel,
"ask": dikte.toggle_ask,
"ask-reset": dikte.reset_conversation,
"meeting": dikte.toggle_meeting, "meeting": dikte.toggle_meeting,
"meeting-cancel": dikte.cancel_meeting, "meeting-cancel": dikte.cancel_meeting,
"settings": dikte.open_settings, "settings": dikte.open_settings,
@@ -590,6 +691,8 @@ def main():
dikte.open_settings() dikte.open_settings()
elif command == "toggle": elif command == "toggle":
QTimer.singleShot(0, dikte.toggle) QTimer.singleShot(0, dikte.toggle)
elif command == "ask":
QTimer.singleShot(0, dikte.toggle_ask)
elif command == "meeting": elif command == "meeting":
QTimer.singleShot(0, dikte.toggle_meeting) QTimer.singleShot(0, dikte.toggle_meeting)
+1
View File
@@ -15,6 +15,7 @@ from i18n import t
DESKTOP_ID = "dikte-toggle.desktop" DESKTOP_ID = "dikte-toggle.desktop"
MEETING_DESKTOP_ID = "dikte-meeting.desktop" MEETING_DESKTOP_ID = "dikte-meeting.desktop"
ASK_DESKTOP_ID = "dikte-ask.desktop"
APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications" APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications"
DESKTOP_FILE = APPLICATIONS_DIR / DESKTOP_ID DESKTOP_FILE = APPLICATIONS_DIR / DESKTOP_ID
SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc" SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc"
+107
View File
@@ -287,6 +287,113 @@ TR = {
"Delete the whole history? This cannot be undone.": "Delete the whole history? This cannot be undone.":
"Geçmişin tamamı silinsin mi? Bu geri alınamaz.", "Geçmişin tamamı silinsin mi? Bu geri alınamaz.",
# --- asking Claude Code -------------------------------------------------
"Ask Claude": "Claude'a sor",
"Stop and ask Claude": "Kaydı bitir ve Claude'a sor",
"Start a new conversation": "Yeni konuşma başlat",
"Start a new conversation now": "Şimdi yeni konuşma başlat",
"Stop Claude": "Claude'u durdur",
"Stopping…": "Durduruluyor…",
"Stopped.": "Durduruldu.",
"Claude starts fresh next time.": "Claude bir sonrakine sıfırdan başlayacak.",
"Dikte: talking to Claude": "Dikte: Claude ile konuşuyor",
"Dikte: recording for Claude": "Dikte: Claude için kaydediyor",
"Asking Claude…": "Claude'a soruluyor…",
"Claude: {preview}": "Claude: {preview}",
"Claude answered, but: {error}": "Claude cevapladı, ama: {error}",
"Dikte: Claude could not do all of it": "Dikte: Claude her şeyi yapamadı",
"Claude was not allowed to use: {tools}":
"Claude şunları kullanamadı: {tools}",
"Running a command…": "Komut çalıştırıyor…",
"Reading…": "Okuyor…",
"Looking through files…": "Dosyalara bakıyor…",
"Searching the files…": "Dosyalarda arıyor…",
"Editing a file…": "Dosya düzenliyor…",
"Writing a file…": "Dosya yazıyor…",
"Searching the web…": "İnternette arıyor…",
"Reading a web page…": "Web sayfası okuyor…",
"Handing it to a subagent…": "Alt ajana devrediyor…",
"Planning…": "Planlıyor…",
"Using {name}": "{name} kullanıyor…",
"claude not found. Install Claude Code and make sure `claude` is on your PATH.":
"claude bulunamadı. Claude Code'u kur ve `claude` komutunun PATH'te "
"olduğundan emin ol.",
"Could not run claude: {error}": "claude çalıştırılamadı: {error}",
"claude exited with code {code}.": "claude {code} koduyla çıktı.",
"Claude did not finish within {seconds} seconds.":
"Claude {seconds} saniye içinde bitirmedi.",
"Claude ended with an error.": "Claude bir hatayla sonlandı.",
"Claude answered with nothing.": "Claude boş cevap verdi.",
# --- settings: Claude ---------------------------------------------------
"Claude": "Claude",
"This shortcut records the same way dictation does, but the transcript is "
"not what gets pasted. It goes to Claude Code as a command, and what comes "
"back is pasted instead: the answer to a question, or a sentence saying "
"what was done. It runs 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 Claude Code'a 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. 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",
"The conversation": "Konuşma",
"The answer": "Cevap",
"Found: {path}": "Bulundu: {path}",
"claude is not on your PATH, so this cannot run yet. Install Claude Code "
"first.":
"claude PATH'te değil, dolayısıyla bu henüz çalışamaz. Önce Claude "
"Code'u kur.",
"No KDE shortcut installed. The tray menu asks Claude too.":
"Kurulu KDE kısayolu yok. Tepsi menüsünden de sorulabilir.",
"A name like “sonnet” always means the newest model of that line. Opus "
"thinks harder and answers slower, which is felt here more than anywhere "
"else: you are standing in front of the screen.":
"“sonnet” gibi bir ad her zaman o serinin en yenisini seçer. Opus daha "
"çok düşünür ve daha geç cevaplar; bu da en çok burada hissedilir, "
"çünkü ekranın başında bekliyorsun.",
"Permissions": "İzinler",
"Decide on its own, with the safety checks on":
"Kendi karar versin, güvenlik denetimleri açık",
"Allow everything": "Her şeye izin ver",
"Only what needs no permission": "Yalnızca izin gerektirmeyenler",
"Working directory": "Çalışma dizini",
"Choose…": "Seç…",
"The directory the command runs in, which decides which project's "
"instructions and files it can see. Your own skills and services are there "
"whichever one it is.":
"Komutun içinde çalıştığı dizin; hangi projenin talimatlarını ve "
"dosyalarını göreceğini bu belirler. Kendi skill'lerin ve servislerin "
"hangi dizin olursa olsun oradadır.",
"Give up after": "Şu süreden sonra vazgeç",
"A command still running after this is given up on. The tray menu can stop "
"one earlier.":
"Bu süreden sonra hâlâ süren komuttan vazgeçilir. Tepsi menüsünden daha "
"erken de durdurulabilir.",
"Carry on for": "Şu kadar süre sürsün",
"every command on its own": "her komut ayrı",
"Commands within this long of each other are one conversation, so “and move "
"that to Thursday” knows what “that” is. After it, the next command starts "
"fresh.":
"Birbirinden bu kadar süre içinde gelen komutlar tek bir konuşmadır; "
"böylece “onu perşembeye al” dediğinde “o”nun ne olduğu bilinir. Bu "
"sürenin ardından bir sonraki komut sıfırdan başlar.",
"No conversation going.": "Süren bir konuşma yok.",
"Last used {minutes} min ago.": "En son {minutes} dk önce kullanıldı.",
"Paste it into the focused window": "Odaktaki pencereye yapıştır",
"It is copied to the clipboard either way.": "Panoya her hâlükârda kopyalanır.",
"Clean the transcript up before sending it": "Göndermeden önce transkripti temizle",
"Off by default: Claude reads through “erm” and “you know” without help, "
"and cleanup costs an API call and a second or two.":
"Varsayılan olarak kapalı: Claude “eee” ve “hani”yi yardımsız da okur, "
"temizlik ise bir API çağrısına ve bir iki saniyeye mal olur.",
"Told to Claude alongside every command, on top of whatever your own "
"configuration already says.":
"Her komutla birlikte Claude'a söylenir, kendi yapılandırmanın zaten "
"söylediklerinin üstüne eklenir.",
" · asked Claude: {question}": " · Claude'a soruldu: {question}",
# --- meetings: tray and pipeline --------------------------------------- # --- meetings: tray and pipeline ---------------------------------------
"Record a meeting": "Toplantı kaydet", "Record a meeting": "Toplantı kaydet",
"End the meeting and write it up": "Toplantıyı bitir ve tutanağı çıkar", "End the meeting and write it up": "Toplantıyı bitir ve tutanağı çıkar",
+12 -8
View File
@@ -23,9 +23,11 @@ ERR = QColor(240, 100, 90)
WARN = QColor(240, 180, 80) WARN = QColor(240, 180, 80)
THEM = QColor(110, 190, 255) # the other side of a meeting THEM = QColor(110, 190, 255) # the other side of a meeting
STATE_COLORS = {"recording": REC, "meeting": REC, "busy": BUSY, "done": OK, ASK = QColor(150, 140, 255) # recording a command rather than a dictation
"warning": WARN, "error": ERR}
LIVE = ("recording", "meeting") STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY,
"done": OK, "warning": WARN, "error": ERR}
LIVE = ("recording", "asking", "meeting")
class Overlay(QWidget): class Overlay(QWidget):
@@ -63,8 +65,10 @@ class Overlay(QWidget):
# ---- public API -------------------------------------------------- # ---- public API --------------------------------------------------
def show_recording(self): def show_recording(self, asking=False):
self.state = "recording" """The same ribbon either way, in a different colour when what is being
recorded is a command for Claude rather than something to paste."""
self.state = "asking" if asking else "recording"
self.message = "" self.message = ""
self.seconds = 0.0 self.seconds = 0.0
self.levels = [0.0] * BARS self.levels = [0.0] * BARS
@@ -202,7 +206,7 @@ class Overlay(QWidget):
self._draw_indicator(painter, accent) self._draw_indicator(painter, accent)
if self.state in LIVE: if self.state in LIVE:
self._draw_waveform(painter) self._draw_waveform(painter, accent)
self._draw_time(painter) self._draw_time(painter)
else: else:
self._draw_message(painter) self._draw_message(painter)
@@ -265,7 +269,7 @@ class Overlay(QWidget):
color.setAlphaF(0.35 + 0.65 * min(1.0, shaped * 2.2)) color.setAlphaF(0.35 + 0.65 * min(1.0, shaped * 2.2))
return color return color
def _draw_waveform(self, painter): def _draw_waveform(self, painter, accent=REC):
if self.state == "meeting": if self.state == "meeting":
self._draw_dual_waveform(painter) self._draw_dual_waveform(painter)
return return
@@ -275,7 +279,7 @@ class Overlay(QWidget):
for i, level in enumerate(self.levels): for i, level in enumerate(self.levels):
shaped = min(1.0, level ** 0.55) shaped = min(1.0, level ** 0.55)
h = 3.0 + shaped * 26.0 h = 3.0 + shaped * 26.0
painter.setBrush(self._bar_colour(shaped, REC)) painter.setBrush(self._bar_colour(shaped, accent))
painter.drawRoundedRect( painter.drawRoundedRect(
QRectF(left + i * step, mid - h / 2, bar_w, h), 1.3, 1.3 QRectF(left + i * step, mid - h / 2, bar_w, h), 1.3, 1.3
) )
+242 -1
View File
@@ -1,6 +1,7 @@
"""Settings window.""" """Settings window."""
import os import os
import shutil
import threading import threading
from PyQt6.QtCore import Qt, QUrl, pyqtSignal from PyQt6.QtCore import Qt, QUrl, pyqtSignal
@@ -13,6 +14,7 @@ from PyQt6.QtWidgets import (
) )
import api import api
import assistant
import audio import audio
import config as cfg import config as cfg
import filetranscribe import filetranscribe
@@ -50,6 +52,16 @@ MEETING_MODELS = [
"google/gemini-3.5-flash", "google/gemini-3.1-pro-preview", "google/gemini-3.5-flash", "google/gemini-3.1-pro-preview",
"anthropic/claude-sonnet-5", "openai/gpt-5.4", "x-ai/grok-4.5", "anthropic/claude-sonnet-5", "openai/gpt-5.4", "x-ai/grok-4.5",
] ]
# 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.
ASSISTANT_MODELS = ["sonnet", "opus", "haiku", "fable"]
# What Claude Code may do without being able to ask. It cannot ask: there is no
# window to answer in, so a mode that would have prompted denies instead.
PERMISSION_MODES = [
("Decide on its own, with the safety checks on", "auto"),
("Allow everything", "bypassPermissions"),
("Only what needs no permission", "manual"),
]
MEETING_STATUS = { MEETING_STATUS = {
"recorded": "waiting to be written up", "recorded": "waiting to be written up",
"transcribed": "transcript ready, minutes missing", "transcribed": "transcript ready, minutes missing",
@@ -76,11 +88,12 @@ class SettingsWindow(QDialog):
_or_test_done = pyqtSignal(bool, str) _or_test_done = pyqtSignal(bool, str)
def __init__(self, conf, launch_command, meeting_command=None, def __init__(self, conf, launch_command, meeting_command=None,
meetings=None, parent=None): meetings=None, ask_command=None, parent=None):
super().__init__(parent) super().__init__(parent)
self.conf = conf self.conf = conf
self.launch_command = launch_command self.launch_command = launch_command
self.meeting_command = meeting_command or launch_command self.meeting_command = meeting_command or launch_command
self.ask_command = ask_command or launch_command
self.meetings = meetings self.meetings = meetings
# Each provider keeps its own transcription model, so switching the # Each provider keeps its own transcription model, so switching the
# provider back and forth never overwrites the other one's. # provider back and forth never overwrites the other one's.
@@ -94,6 +107,7 @@ class SettingsWindow(QDialog):
tabs.addTab(self._general_tab(), t("General")) tabs.addTab(self._general_tab(), t("General"))
tabs.addTab(self._api_tab(), t("API and models")) tabs.addTab(self._api_tab(), t("API and models"))
tabs.addTab(self._prompt_tab(), t("Cleanup rules")) tabs.addTab(self._prompt_tab(), t("Cleanup rules"))
tabs.addTab(self._assistant_tab(), t("Claude"))
tabs.addTab(self._meeting_tab(), t("Meeting")) tabs.addTab(self._meeting_tab(), t("Meeting"))
tabs.addTab(self._minutes_tab(), t("Minutes")) tabs.addTab(self._minutes_tab(), t("Minutes"))
tabs.addTab(self._file_tab(), t("Audio file")) tabs.addTab(self._file_tab(), t("Audio file"))
@@ -304,6 +318,134 @@ class SettingsWindow(QDialog):
layout.addWidget(self.transcribe_prompt) layout.addWidget(self.transcribe_prompt)
return page return page
def _assistant_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
intro = QLabel(t(
"This shortcut records the same way dictation does, but the "
"transcript is not what gets pasted. It goes to Claude Code as a "
"command, and what comes back is pasted instead: the answer to a "
"question, or a sentence saying what was done. It runs as the "
"session you would have opened yourself, with your skills, your "
"connected services and your account."
))
intro.setWordWrap(True)
layout.addWidget(intro)
self.assistant_found = QLabel("")
self.assistant_found.setWordWrap(True)
layout.addWidget(self.assistant_found)
how = QGroupBox(t("How it runs"))
how_form = QFormLayout(how)
self.assistant_shortcut = QLineEdit()
self.assistant_shortcut.setPlaceholderText(t("none"))
install = QPushButton(t("Install as a KDE shortcut"))
install.clicked.connect(self._install_ask_shortcut)
remove = QPushButton(t("Remove"))
remove.clicked.connect(self._remove_ask_shortcut)
how_form.addRow(t("Shortcut"),
self._row(self.assistant_shortcut, install, remove))
self.assistant_shortcut_status = QLabel("")
self.assistant_shortcut_status.setWordWrap(True)
how_form.addRow(self.assistant_shortcut_status)
self.assistant_model = QComboBox()
self.assistant_model.setEditable(True)
self.assistant_model.addItems(ASSISTANT_MODELS)
self.assistant_model.setToolTip(t(
"A name like “sonnet” always means the newest model of that line. "
"Opus thinks harder and answers slower, which is felt here more "
"than anywhere else: you are standing in front of the screen."
))
how_form.addRow(t("Model"), self.assistant_model)
self.assistant_permission = QComboBox()
for label, value in PERMISSION_MODES:
self.assistant_permission.addItem(t(label), value)
how_form.addRow(t("Permissions"), self.assistant_permission)
self.assistant_dir = QLineEdit()
self.assistant_dir.setPlaceholderText(os.path.expanduser("~"))
browse = QPushButton(t("Choose…"))
browse.clicked.connect(self._choose_assistant_dir)
how_form.addRow(t("Working directory"),
self._row(self.assistant_dir, browse))
dir_note = QLabel(t(
"The directory the command runs in, which decides which project's "
"instructions and files it can see. Your own skills and services "
"are there whichever one it is."
))
dir_note.setWordWrap(True)
how_form.addRow(dir_note)
self.assistant_timeout = QSpinBox()
self.assistant_timeout.setRange(15, 3600)
self.assistant_timeout.setSuffix(t(" s"))
self.assistant_timeout.setToolTip(t(
"A command still running after this is given up on. The tray menu "
"can stop one earlier."
))
how_form.addRow(t("Give up after"), self.assistant_timeout)
layout.addWidget(how)
thread = QGroupBox(t("The conversation"))
thread_form = QFormLayout(thread)
self.assistant_session_minutes = QSpinBox()
self.assistant_session_minutes.setRange(0, 1440)
self.assistant_session_minutes.setSuffix(t(" min"))
self.assistant_session_minutes.setSpecialValueText(t("every command on its own"))
thread_form.addRow(t("Carry on for"), self.assistant_session_minutes)
thread_note = QLabel(t(
"Commands within this long of each other are one conversation, so "
"“and move that to Thursday” knows what “that” is. After it, the "
"next command starts fresh."
))
thread_note.setWordWrap(True)
thread_form.addRow(thread_note)
reset = QPushButton(t("Start a new conversation now"))
reset.clicked.connect(self._reset_assistant_session)
self.assistant_session_status = QLabel("")
self.assistant_session_status.setWordWrap(True)
thread_form.addRow(self._row(reset), self.assistant_session_status)
layout.addWidget(thread)
answer = QGroupBox(t("The answer"))
answer_form = QFormLayout(answer)
self.assistant_paste = QCheckBox(t("Paste it into the focused window"))
self.assistant_paste.setToolTip(t(
"It is copied to the clipboard either way."
))
answer_form.addRow("", self.assistant_paste)
self.assistant_cleanup = QCheckBox(t("Clean the transcript up before sending it"))
self.assistant_cleanup.setToolTip(t(
"Off by default: Claude reads through “erm” and “you know” without "
"help, and cleanup costs an API call and a second or two."
))
answer_form.addRow("", self.assistant_cleanup)
layout.addWidget(answer)
prompt_label = QLabel(t(
"Told to Claude alongside every command, on top of whatever your "
"own configuration already says."
))
prompt_label.setWordWrap(True)
layout.addWidget(prompt_label)
self.assistant_prompt = QPlainTextEdit()
self.assistant_prompt.setMinimumHeight(180)
layout.addWidget(self.assistant_prompt, 1)
reset_prompt = QPushButton(t("Reset to default"))
reset_prompt.clicked.connect(
lambda: self.assistant_prompt.setPlainText(cfg.default_assistant_prompt())
)
layout.addWidget(reset_prompt, 0, Qt.AlignmentFlag.AlignRight)
area = QScrollArea()
area.setWidgetResizable(True)
area.setFrameShape(QScrollArea.Shape.NoFrame)
area.setWidget(page)
return area
def _meeting_tab(self): def _meeting_tab(self):
page = QWidget() page = QWidget()
layout = QVBoxLayout(page) layout = QVBoxLayout(page)
@@ -677,6 +819,18 @@ class SettingsWindow(QDialog):
self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt()) self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt())
self.transcribe_prompt.setPlainText(conf["transcribe_prompt"]) self.transcribe_prompt.setPlainText(conf["transcribe_prompt"])
self.assistant_shortcut.setText(conf["assistant_shortcut"])
self.assistant_model.setCurrentText(conf["assistant_model"])
self._select_data(self.assistant_permission, conf["assistant_permission_mode"])
self.assistant_dir.setText(conf["assistant_dir"])
self.assistant_timeout.setValue(int(conf["assistant_timeout"]))
self.assistant_session_minutes.setValue(int(conf["assistant_session_minutes"]))
self.assistant_paste.setChecked(conf["assistant_paste"])
self.assistant_cleanup.setChecked(conf["assistant_cleanup"])
self.assistant_prompt.setPlainText(
conf["assistant_prompt"] or cfg.default_assistant_prompt()
)
self._select_data(self.meeting_mic, conf["meeting_mic_target"]) self._select_data(self.meeting_mic, conf["meeting_mic_target"])
self._select_data(self.meeting_system, conf["meeting_system_target"]) self._select_data(self.meeting_system, conf["meeting_system_target"])
self.meeting_self_name.setText(conf["meeting_self_name"]) self.meeting_self_name.setText(conf["meeting_self_name"])
@@ -704,6 +858,8 @@ class SettingsWindow(QDialog):
self._refresh_shortcut_status() self._refresh_shortcut_status()
self._refresh_meeting_shortcut_status() self._refresh_meeting_shortcut_status()
self._refresh_ask_shortcut_status()
self._refresh_assistant_status()
self._load_history() self._load_history()
self._load_minutes() self._load_minutes()
@@ -742,6 +898,20 @@ class SettingsWindow(QDialog):
conf["cleanup_prompt"] = "" if prompt == cfg.default_cleanup_prompt() else prompt conf["cleanup_prompt"] = "" if prompt == cfg.default_cleanup_prompt() else prompt
conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip() conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip()
conf["assistant_shortcut"] = self.assistant_shortcut.text().strip()
conf["assistant_model"] = (self.assistant_model.currentText().strip()
or cfg.DEFAULTS["assistant_model"])
conf["assistant_permission_mode"] = (self.assistant_permission.currentData()
or "auto")
conf["assistant_dir"] = self.assistant_dir.text().strip()
conf["assistant_timeout"] = self.assistant_timeout.value()
conf["assistant_session_minutes"] = self.assistant_session_minutes.value()
conf["assistant_paste"] = self.assistant_paste.isChecked()
conf["assistant_cleanup"] = self.assistant_cleanup.isChecked()
assistant_prompt = self.assistant_prompt.toPlainText().strip()
conf["assistant_prompt"] = ("" if assistant_prompt == cfg.default_assistant_prompt()
else assistant_prompt)
conf["meeting_mic_target"] = self.meeting_mic.currentData() or "" conf["meeting_mic_target"] = self.meeting_mic.currentData() or ""
conf["meeting_system_target"] = self.meeting_system.currentData() or "" conf["meeting_system_target"] = self.meeting_system.currentData() or ""
conf["meeting_self_name"] = self.meeting_self_name.text().strip() conf["meeting_self_name"] = self.meeting_self_name.text().strip()
@@ -1034,6 +1204,71 @@ class SettingsWindow(QDialog):
else t("No KDE shortcut installed. The tray menu starts a meeting too.") else t("No KDE shortcut installed. The tray menu starts a meeting too.")
) )
# ---- Claude ----------------------------------------------------------
def _install_ask_shortcut(self):
combo = self.assistant_shortcut.text().strip()
if not combo:
QMessageBox.information(self, t("Shortcut"),
t("Type a key combination first."))
return
clashes = hotkey.conflicting_shortcuts(combo, hotkey.ASK_DESKTOP_ID)
if clashes:
answer = QMessageBox.question(
self, t("Shortcut conflict"),
t("{shortcut} is also used by:\n\n{list}\n\nInstall anyway?",
shortcut=combo, list="\n".join(clashes[:6])),
)
if answer != QMessageBox.StandardButton.Yes:
return
ok, message = hotkey.install_kde_shortcut(
combo, self.ask_command, name="Dikte: ask Claude Code",
desktop_id=hotkey.ASK_DESKTOP_ID,
)
QMessageBox.information(self, t("Shortcut"), message)
if ok:
self.conf["assistant_shortcut"] = combo
self.conf.save()
self._refresh_ask_shortcut_status()
def _remove_ask_shortcut(self):
hotkey.remove_kde_shortcut(hotkey.ASK_DESKTOP_ID)
self._refresh_ask_shortcut_status()
def _refresh_ask_shortcut_status(self):
current = hotkey.kde_shortcut_status(hotkey.ASK_DESKTOP_ID)
self.assistant_shortcut_status.setText(
t("Registered in KDE: {shortcut}", shortcut=current) if current
else t("No KDE shortcut installed. The tray menu asks Claude too.")
)
def _refresh_assistant_status(self):
found = shutil.which("claude")
self.assistant_found.setText(
t("Found: {path}", path=found) if found else
t("claude is not on your PATH, so this cannot run yet. Install "
"Claude Code first.")
)
age = assistant.session_age()
if age is None:
self.assistant_session_status.setText(t("No conversation going."))
else:
self.assistant_session_status.setText(
t("Last used {minutes} min ago.", minutes=int(age // 60))
)
def _reset_assistant_session(self):
assistant.clear_session()
self._refresh_assistant_status()
def _choose_assistant_dir(self):
chosen = QFileDialog.getExistingDirectory(
self, t("Working directory"),
self.assistant_dir.text().strip() or os.path.expanduser("~"),
)
if chosen:
self.assistant_dir.setText(chosen)
# ---- minutes --------------------------------------------------------- # ---- minutes ---------------------------------------------------------
def _load_minutes(self): def _load_minutes(self):
@@ -1121,6 +1356,12 @@ class SettingsWindow(QDialog):
preview = text[:110] + ("" if len(text) > 110 else "") preview = text[:110] + ("" if len(text) > 110 else "")
header = t("{ts} ({duration} s)", header = t("{ts} ({duration} s)",
ts=row.get("ts", ""), duration=row.get("duration", 0)) ts=row.get("ts", ""), duration=row.get("duration", 0))
if row.get("mode") == "ask":
# 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}",
question=asked[:60] + ("" if len(asked) > 60 else ""))
item = QListWidgetItem(f"{header}\n{preview}") item = QListWidgetItem(f"{header}\n{preview}")
item.setData(Qt.ItemDataRole.UserRole, row) item.setData(Qt.ItemDataRole.UserRole, row)
self.history.addItem(item) self.history.addItem(item)
+46 -7
View File
@@ -1,4 +1,10 @@
"""The dictation chain: transcribe → clean up → clipboard → paste.""" """The dictation chain: transcribe → clean up → clipboard → paste.
The same chain also carries the other thing a dictation can be. Asked to, it
hands the transcript to Claude Code instead of pasting it, and pastes back
whatever came of it: an answer to a question, or a sentence saying what was
done.
"""
import os import os
import shutil import shutil
@@ -10,6 +16,7 @@ import traceback
from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtCore import QObject, pyqtSignal
import api import api
import assistant
import audio import audio
import config as cfg import config as cfg
import paste import paste
@@ -23,25 +30,38 @@ class Pipeline(QObject):
stage = pyqtSignal(str) # human-readable progress line stage = pyqtSignal(str) # human-readable progress line
finished = pyqtSignal(str, str, str) # raw transcript, final text, warning finished = pyqtSignal(str, str, str) # raw transcript, final text, warning
failed = pyqtSignal(str) failed = pyqtSignal(str)
cancelled = pyqtSignal()
def __init__(self, conf, parent=None): def __init__(self, conf, parent=None):
super().__init__(parent) super().__init__(parent)
self.conf = conf self.conf = conf
self._thread = None self._thread = None
self._stop = threading.Event()
@property @property
def busy(self): def busy(self):
return self._thread is not None and self._thread.is_alive() return self._thread is not None and self._thread.is_alive()
def run(self, wav_path, duration, rms_values=()): def run(self, wav_path, duration, rms_values=(), ask=False):
if self.busy: if self.busy:
return return
self._stop.clear()
self._thread = threading.Thread( self._thread = threading.Thread(
target=self._work, args=(wav_path, duration, list(rms_values)), daemon=True target=self._work, args=(wav_path, duration, list(rms_values), ask),
daemon=True,
) )
self._thread.start() self._thread.start()
def _work(self, wav_path, duration, rms_values): def cancel(self):
"""Give up on a job already under way.
Only the Claude call can honour this, and it is the only one long enough
to be worth interrupting: a transcription is over in seconds, a command
that went looking through the web is not.
"""
self._stop.set()
def _work(self, wav_path, duration, rms_values, ask):
conf = self.conf conf = self.conf
started = time.monotonic() started = time.monotonic()
raw = "" raw = ""
@@ -75,7 +95,10 @@ class Pipeline(QObject):
text = raw text = raw
warning = "" warning = ""
if conf["cleanup_enabled"]: # Claude reads through “eee” and “hani” without help, so a dictation
# on its way there is normally sent as it was heard, one API call and
# a second or two lighter.
if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]):
self.stage.emit(t("Cleaning up…")) self.stage.emit(t("Cleaning up…"))
try: try:
text = api.cleanup( text = api.cleanup(
@@ -93,10 +116,21 @@ class Pipeline(QObject):
warning = str(exc) warning = str(exc)
print(f"dikte: cleanup failed: {exc}", file=sys.stderr) print(f"dikte: cleanup failed: {exc}", file=sys.stderr)
question = ""
if ask:
question = text
self.stage.emit(t("Asking Claude…"))
text, denied = assistant.ask(
question, conf,
on_stage=self.stage.emit,
should_stop=self._stop.is_set,
)
warning = "\n".join(x for x in (warning, denied) if x)
previous = paste.read_clipboard() if conf["restore_clipboard"] else None previous = paste.read_clipboard() if conf["restore_clipboard"] else None
paste.copy(text) paste.copy(text)
if conf["auto_paste"]: if (conf["assistant_paste"] if ask else conf["auto_paste"]):
self.stage.emit(t("Pasting…")) self.stage.emit(t("Pasting…"))
paste.press(conf["paste_shortcut"]) paste.press(conf["paste_shortcut"])
if previous is not None: if previous is not None:
@@ -110,6 +144,9 @@ class Pipeline(QObject):
"model": target.model, "model": target.model,
"cleanup_model": conf["cleanup_model"] if conf["cleanup_enabled"] else "", "cleanup_model": conf["cleanup_model"] if conf["cleanup_enabled"] else "",
"cleanup_error": warning, "cleanup_error": warning,
"mode": "ask" if ask else "",
"question": question,
"assistant_model": conf["assistant_model"] if ask else "",
"raw": raw, "raw": raw,
"text": text, "text": text,
}) })
@@ -119,7 +156,9 @@ class Pipeline(QObject):
print(f"dikte: could not trim the history: {exc}", file=sys.stderr) print(f"dikte: could not trim the history: {exc}", file=sys.stderr)
self.finished.emit(raw, text, warning) self.finished.emit(raw, text, warning)
except (api.ApiError, paste.PasteError) as exc: except assistant.Cancelled:
self.cancelled.emit()
except (api.ApiError, paste.PasteError, assistant.AssistantError) as exc:
print(f"dikte: {exc}", file=sys.stderr) print(f"dikte: {exc}", file=sys.stderr)
self.failed.emit(str(exc)) self.failed.emit(str(exc))
except Exception as exc: # never fail silently except Exception as exc: # never fail silently