mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user