diff --git a/README.md b/README.md index 34f27bf..5160ba6 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ set next to it. | --- | --- | | Start / stop recording | `Ctrl+Space`, or click the tray icon | | 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` | | Settings | Tray menu → *Settings*, or `dikte settings` | | 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 dictation is not lost, but the indicator turns amber with the reason instead of 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 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 @@ -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 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 and reading it back api.py transcription on either provider, OpenRouter cleanup (stdlib only) worker.py transcribe → clean up → clipboard → paste vad.py deciding whether a recording holds speech at all diff --git a/README.tr.md b/README.tr.md index 89bc4a2..c5f7723 100644 --- a/README.tr.md +++ b/README.tr.md @@ -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ı 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` | | 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` | @@ -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 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. +- **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 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, @@ -110,6 +118,7 @@ grubunda olmasını gerektirir: `sudo usermod -aG input $USER`. dikte.py giriş noktası, tepsi simgesi, durum makinesi, IPC 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'dan geçirip cevabı geri okuma api.py iki sağlayıcıda transkript + OpenRouter temizleme (yalnız stdlib) worker.py transkript → temizleme → pano → yapıştırma vad.py kayıtta gerçekten konuşma var mı kararı diff --git a/assistant.py b/assistant.py new file mode 100644 index 0000000..7d686e3 --- /dev/null +++ b/assistant.py @@ -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 "" diff --git a/config.py b/config.py index 4a8ce19..63b508d 100644 --- a/config.py +++ b/config.py @@ -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 " "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 = { "ui_language": "auto", # auto | tr | en "openai_api_key": "", @@ -268,6 +312,17 @@ DEFAULTS = { "meeting_participants": "", "meeting_keep_audio": False, # a failed run keeps its audio regardless "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 @@ -362,6 +417,9 @@ class Config: prompt += SPEAKER_RULE_TR if turkish else SPEAKER_RULE_EN return prompt + def assistant_prompt(self): + return self["assistant_prompt"].strip() or default_assistant_prompt() + # ---- meetings -------------------------------------------------------- def participants(self): @@ -411,6 +469,10 @@ def default_meeting_prompt(): 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): DATA_DIR.mkdir(parents=True, exist_ok=True) with open(HISTORY_FILE, "a", encoding="utf-8") as fh: diff --git a/dikte.py b/dikte.py index fa84c8c..59dd3e8 100755 --- a/dikte.py +++ b/dikte.py @@ -5,6 +5,8 @@ Usage: dikte.py run in the background (tray icon) dikte.py toggle start / stop 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-cancel discard the meeting being recorded 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.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402 +import assistant # noqa: E402 import audio # noqa: E402 import config as cfg # noqa: E402 import hotkey # noqa: E402 @@ -57,6 +60,10 @@ class Dikte: self.app = app self.conf = cfg.Config() 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_base = "" self.meeting_message = "" @@ -76,6 +83,7 @@ class Dikte: self.pipeline.stage.connect(self.overlay.show_busy) self.pipeline.finished.connect(self._on_finished) 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.stopped.connect(self._on_meeting_recorded) self.meeting_recorder.died.connect(self._on_meeting_died) @@ -111,6 +119,14 @@ class Dikte: self.toggle_action.triggered.connect(self._toggle) 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.triggered.connect(self.cancel) self.cancel_action.setEnabled(False) @@ -159,6 +175,8 @@ class Dikte: def _set_state(self, state): self.state = state + if state == IDLE: + self.ask_mode = False self._refresh_tray() def _set_meeting_state(self, state): @@ -174,9 +192,33 @@ class Dikte: BUSY: ("Working…", "view-refresh", "Dikte: working"), } 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.setEnabled(self.state != BUSY) - self.cancel_action.setEnabled(self.state == RECORDING) + # Each of the two owns its own entry, so the one that is not 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 = { M_IDLE: "Record a meeting", @@ -207,6 +249,9 @@ class Dikte: """A toggle from outside this process: the KDE shortcut, or the CLI.""" self._external("toggle", self._toggle) + def toggle_ask(self): + self._external("ask", self._toggle_ask) + def toggle_meeting(self): self._external("meeting", self._toggle_meeting) @@ -227,7 +272,8 @@ class Dikte: if timer is None: timer = self.last_evdev[name] = QElapsedTimer() 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): self.evdev.stop() @@ -243,25 +289,41 @@ class Dikte: def _toggle(self): # 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. - if self.last_toggle.isValid() and self.last_toggle.elapsed() < 400: + if self._repeated(): return - self.last_toggle.restart() - if self.state == IDLE: self.start() - elif self.state == RECORDING: + elif self.state == RECORDING and not self.ask_mode: 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: return - self.overlay.show_recording() + self.ask_mode = ask + self.overlay.show_recording(asking=ask) self.elapsed.restart() self.ticker.start() self._set_state(RECORDING) self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"]) + def start_ask(self): + self.start(ask=True) + def stop(self): if self.state != RECORDING: return @@ -271,6 +333,13 @@ class Dikte: self.recorder.stop() 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: return self.ticker.stop() @@ -278,6 +347,12 @@ class Dikte: self.overlay.dismiss() 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): seconds = self.elapsed.elapsed() / 1000.0 self.overlay.set_seconds(seconds) @@ -414,24 +489,41 @@ class Dikte: self.stop_meeting() 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): + asked = self.ask_mode if warning: - # The text was still pasted, but cleanup did not run. Say so loudly: - # a rejected key otherwise looks exactly like working dictation. + # The text was still pasted, but something on the way did not run. + # 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( - 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( - 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, ) else: preview = text.replace("\n", " ") preview = preview[:48] + ("…" if len(preview) > 48 else "") - action = t("Pasted") if self.conf["auto_paste"] else t("Copied") - self.overlay.show_done(t("{action}: {preview}", action=action, preview=preview)) + if asked: + # 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) def _on_error(self, message): @@ -448,7 +540,8 @@ class Dikte: def open_settings(self): if self.settings_window is None: 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.finished.connect(self._settings_closed) @@ -466,6 +559,7 @@ class Dikte: self._refresh_tray() if self.conf["evdev_hotkey"]: self.evdev.start({"toggle": self.conf["shortcut"], + "ask": self.conf["assistant_shortcut"], "meeting": self.conf["meeting_shortcut"]}) else: self.evdev.stop() @@ -509,6 +603,10 @@ def meeting_command(): 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): """Hand a command to the running instance; False when there is none.""" socket = QLocalSocket() @@ -527,8 +625,8 @@ def main(): command = args[0] if args else "" if command and command not in ("toggle", "cancel", "settings", "restart", - "quit", "start", "stop", "meeting", - "meeting-cancel"): + "quit", "start", "stop", "ask", "ask-reset", + "meeting", "meeting-cancel"): print(__doc__) return 2 @@ -541,7 +639,8 @@ def main(): if send_command(command or "settings"): return 0 - if command in ("cancel", "quit", "stop", "restart", "meeting-cancel"): + if command in ("cancel", "quit", "stop", "restart", "meeting-cancel", + "ask-reset"): return 0 if not QSystemTrayIcon.isSystemTrayAvailable(): @@ -569,6 +668,8 @@ def main(): "start": dikte.start, "stop": dikte.stop, "cancel": dikte.cancel, + "ask": dikte.toggle_ask, + "ask-reset": dikte.reset_conversation, "meeting": dikte.toggle_meeting, "meeting-cancel": dikte.cancel_meeting, "settings": dikte.open_settings, @@ -590,6 +691,8 @@ def main(): dikte.open_settings() elif command == "toggle": QTimer.singleShot(0, dikte.toggle) + elif command == "ask": + QTimer.singleShot(0, dikte.toggle_ask) elif command == "meeting": QTimer.singleShot(0, dikte.toggle_meeting) diff --git a/hotkey.py b/hotkey.py index 70e7bae..77f1163 100644 --- a/hotkey.py +++ b/hotkey.py @@ -15,6 +15,7 @@ from i18n import t DESKTOP_ID = "dikte-toggle.desktop" MEETING_DESKTOP_ID = "dikte-meeting.desktop" +ASK_DESKTOP_ID = "dikte-ask.desktop" APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications" DESKTOP_FILE = APPLICATIONS_DIR / DESKTOP_ID SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc" diff --git a/i18n.py b/i18n.py index c9dff3b..f19cc9a 100644 --- a/i18n.py +++ b/i18n.py @@ -287,6 +287,113 @@ TR = { "Delete the whole history? This cannot be undone.": "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 --------------------------------------- "Record a meeting": "Toplantı kaydet", "End the meeting and write it up": "Toplantıyı bitir ve tutanağı çıkar", diff --git a/overlay.py b/overlay.py index f1234ae..a2273cb 100644 --- a/overlay.py +++ b/overlay.py @@ -23,9 +23,11 @@ ERR = QColor(240, 100, 90) WARN = QColor(240, 180, 80) THEM = QColor(110, 190, 255) # the other side of a meeting -STATE_COLORS = {"recording": REC, "meeting": REC, "busy": BUSY, "done": OK, - "warning": WARN, "error": ERR} -LIVE = ("recording", "meeting") +ASK = QColor(150, 140, 255) # recording a command rather than a dictation + +STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY, + "done": OK, "warning": WARN, "error": ERR} +LIVE = ("recording", "asking", "meeting") class Overlay(QWidget): @@ -63,8 +65,10 @@ class Overlay(QWidget): # ---- public API -------------------------------------------------- - def show_recording(self): - self.state = "recording" + def show_recording(self, asking=False): + """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.seconds = 0.0 self.levels = [0.0] * BARS @@ -202,7 +206,7 @@ class Overlay(QWidget): self._draw_indicator(painter, accent) if self.state in LIVE: - self._draw_waveform(painter) + self._draw_waveform(painter, accent) self._draw_time(painter) else: self._draw_message(painter) @@ -265,7 +269,7 @@ class Overlay(QWidget): color.setAlphaF(0.35 + 0.65 * min(1.0, shaped * 2.2)) return color - def _draw_waveform(self, painter): + def _draw_waveform(self, painter, accent=REC): if self.state == "meeting": self._draw_dual_waveform(painter) return @@ -275,7 +279,7 @@ class Overlay(QWidget): for i, level in enumerate(self.levels): shaped = min(1.0, level ** 0.55) h = 3.0 + shaped * 26.0 - painter.setBrush(self._bar_colour(shaped, REC)) + painter.setBrush(self._bar_colour(shaped, accent)) painter.drawRoundedRect( QRectF(left + i * step, mid - h / 2, bar_w, h), 1.3, 1.3 ) diff --git a/settings_ui.py b/settings_ui.py index 615e17e..a12ea6c 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -1,6 +1,7 @@ """Settings window.""" import os +import shutil import threading from PyQt6.QtCore import Qt, QUrl, pyqtSignal @@ -13,6 +14,7 @@ from PyQt6.QtWidgets import ( ) import api +import assistant import audio import config as cfg import filetranscribe @@ -50,6 +52,16 @@ MEETING_MODELS = [ "google/gemini-3.5-flash", "google/gemini-3.1-pro-preview", "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 = { "recorded": "waiting to be written up", "transcribed": "transcript ready, minutes missing", @@ -76,11 +88,12 @@ class SettingsWindow(QDialog): _or_test_done = pyqtSignal(bool, str) def __init__(self, conf, launch_command, meeting_command=None, - meetings=None, parent=None): + meetings=None, ask_command=None, parent=None): super().__init__(parent) self.conf = conf self.launch_command = launch_command self.meeting_command = meeting_command or launch_command + self.ask_command = ask_command or launch_command self.meetings = meetings # Each provider keeps its own transcription model, so switching the # 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._api_tab(), t("API and models")) 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._minutes_tab(), t("Minutes")) tabs.addTab(self._file_tab(), t("Audio file")) @@ -304,6 +318,134 @@ class SettingsWindow(QDialog): layout.addWidget(self.transcribe_prompt) 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): page = QWidget() layout = QVBoxLayout(page) @@ -677,6 +819,18 @@ class SettingsWindow(QDialog): self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_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_system, conf["meeting_system_target"]) self.meeting_self_name.setText(conf["meeting_self_name"]) @@ -704,6 +858,8 @@ class SettingsWindow(QDialog): self._refresh_shortcut_status() self._refresh_meeting_shortcut_status() + self._refresh_ask_shortcut_status() + self._refresh_assistant_status() self._load_history() self._load_minutes() @@ -742,6 +898,20 @@ class SettingsWindow(QDialog): conf["cleanup_prompt"] = "" if prompt == cfg.default_cleanup_prompt() else prompt 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_system_target"] = self.meeting_system.currentData() or "" 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.") ) + # ---- 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 --------------------------------------------------------- def _load_minutes(self): @@ -1121,6 +1356,12 @@ class SettingsWindow(QDialog): preview = text[:110] + ("…" if len(text) > 110 else "") header = t("{ts} ({duration} s)", 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.setData(Qt.ItemDataRole.UserRole, row) self.history.addItem(item) diff --git a/worker.py b/worker.py index b5dc8ef..07a2227 100644 --- a/worker.py +++ b/worker.py @@ -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 shutil @@ -10,6 +16,7 @@ import traceback from PyQt6.QtCore import QObject, pyqtSignal import api +import assistant import audio import config as cfg import paste @@ -23,25 +30,38 @@ class Pipeline(QObject): stage = pyqtSignal(str) # human-readable progress line finished = pyqtSignal(str, str, str) # raw transcript, final text, warning failed = pyqtSignal(str) + cancelled = pyqtSignal() def __init__(self, conf, parent=None): super().__init__(parent) self.conf = conf self._thread = None + self._stop = threading.Event() @property def busy(self): 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: return + self._stop.clear() 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() - 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 started = time.monotonic() raw = "" @@ -75,7 +95,10 @@ class Pipeline(QObject): text = raw 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…")) try: text = api.cleanup( @@ -93,10 +116,21 @@ class Pipeline(QObject): warning = str(exc) 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 paste.copy(text) - if conf["auto_paste"]: + if (conf["assistant_paste"] if ask else conf["auto_paste"]): self.stage.emit(t("Pasting…")) paste.press(conf["paste_shortcut"]) if previous is not None: @@ -110,6 +144,9 @@ class Pipeline(QObject): "model": target.model, "cleanup_model": conf["cleanup_model"] if conf["cleanup_enabled"] else "", "cleanup_error": warning, + "mode": "ask" if ask else "", + "question": question, + "assistant_model": conf["assistant_model"] if ask else "", "raw": raw, "text": text, }) @@ -119,7 +156,9 @@ class Pipeline(QObject): print(f"dikte: could not trim the history: {exc}", file=sys.stderr) 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) self.failed.emit(str(exc)) except Exception as exc: # never fail silently