Commit Graph
56 Commits
Author SHA1 Message Date
Yusuf İpek 53ab60c451 Merge pull request #19 from benfirad/codex/macos-clipboard-native
Preserve every native macOS clipboard format
2026-08-05 15:02:48 +03:00
firat ca6444c011 Preserve native macOS clipboard contents 2026-08-03 18:57:43 +02:00
yusufipk 822cf3b2a3 Merge master: the shortcut table, local models, and a Mac still in them
Four files disagreed, and all four the same way: master had turned things the
Mac branch wrote out by hand into one list to read from.

Shortcuts are the whole of it. master gave every binding a row in
hotkey.SHORTCUTS, so the Mac's DESKTOP_IDS is gone and CarbonHotkey reads the
desktop id off that row, which also gives the new cancel key a status line on a
Mac. Settings builds its four rows through master's _shortcut_row, and that one
now asks _install_buttons for Install and Remove, so macOS gets a combination
box and nothing to press, and everywhere else the button says the desktop's own
name. dikte.py starts the listener from the same table, on macOS whatever the
setting says: there is nothing installed for it to be a fallback to.

The rest is two imports and a paste list that lives in paste.Desktop now.
2026-08-02 09:36:22 +03:00
yusufipk 7a507c52eb Make the audio file tab remember, and its Stop stop
The two switches were written to disk by the Save button at the far end of
the window, so a file transcribed with timestamps and cleanup was
transcribed without either the next time. They belong to the run rather than
to the form: they go to disk as they are ticked now, and the folder the last
file came from goes with them.

Stop only set a flag that was looked at between chunks, and a file under ten
minutes is one chunk, so for most files it was looked at after the work it
was meant to stop had already finished. Nothing that blocks is reached by a
flag. The request is inside urlopen, ffmpeg is inside communicate, and a
whisper on this machine is a process of ours that would grind on to the end
of the chunk with nobody left to hand the answer to. So the socket is shut
down under the read, ffmpeg is killed, and a local server is stopped and
left for the next run to start again.

Shutting the socket down rather than closing it is the point: close() alone
leaves a thread already inside recv() waiting for bytes that are never
coming now. The connection is registered before it has a socket, so a stop
landing in the few lines between making a connection and blocking on it
refuses the connection rather than missing it and letting urllib quietly
open another.
2026-08-02 09:29:12 +03:00
yusufipk fc333fcb8c Reshoot every tab, and put the local models first
The settings window has four tabs it did not have when these were taken:
Agent, Meeting, Minutes and Shortcuts. Replace the four screenshots with
seven, at the same size and lossless as before. History goes without a
replacement rather than showing an old tab bar beside the new ones.

Running here is what the program does by default, and it was buried in a
paragraph under Install. Make it the first thing the feature list says,
and cut that paragraph back so the two do not repeat each other.
2026-08-01 21:05:46 +03:00
Yusuf İpek 74ca6621ef Merge pull request #11 from yusufipk/local-models
Transcribe and clean up on this machine, fetching both programs
2026-08-01 20:45:20 +03:00
yusufipk 97396d4734 Refuse a download that was published without a checksum
The hash was checked when there was one and skipped when there was not,
which is the wrong way round: everything fetched here is either run or
parsed by something written in C++, and the case with no hash is the one
where that matters most and says least.

Not hypothetical. GitHub publishes a digest for whisper.cpp v1.9.1 and for
llama.cpp today, and publishes none for whisper.cpp v1.8.0 and older, so
anything reaching for an older tag would have installed an executable
nobody checked.
2026-08-01 20:42:47 +03:00
yusufipk 785ceba02a Merge master: Groq, and cleanup on a subscription
Three of master's changes land on the same lines as this branch, so most of
this is picking the newer shape and putting the local half back into it.

cleanup.py arrived while this was being written and is the right place for
a fourth provider, so the Target refactor of api.cleanup goes away: llama.cpp
becomes a name in cleanup.PROVIDERS next to OpenRouter, Claude Code and
Codex, and worker.py, meeting.py and filetranscribe.py go back to master's.

The settings window keeps master's one row per provider, hidden with
setRowVisible, rather than the two wrapper widgets this branch had. So does
speech to text, which was doing the same thing its own way.

The transcriber table has no room for a provider with no key and no base
URL, so the local one is answered before the lookup rather than added to
it, and an unknown name now falls back to openai by name: the shipped
default is no longer a key of that table.

The minutes stay on OpenRouter, which master already decided by routing
only the transcript through cleanup.run, so meeting_provider goes.
2026-08-01 20:34:24 +03:00
yusufipk 593d307ecc Show a model name whole in the list it is chosen from
A combo box hands its own width to the list under it and elides whatever
does not fit, in the middle of the word: "ggml-org/Qwen....7B-Base-GGUF"
is not something anybody can choose between. The list is now as wide as its
longest row while the box stays the width the form gave it.
2026-08-01 20:16:38 +03:00
yusufipk f3573908d0 Count a model bigger than two gigabytes upwards
Qt's int is C++'s 32-bit one, so the progress of a 2.3 GB download arrived
at the other end of the signal as -1805209440, which is the real figure
less 2^32. The file was landing correctly; only the line under it was
wrong, reading "1.0 MB of -1805209440 B (-1%)".
2026-08-01 20:14:50 +03:00
yusufipk 634b46fcb0 Take the process with a start that goes wrong
_wait_ready can raise rather than return, and the process it was waiting on
is ours with nothing else holding a reference to it. Leaving it running
leaks a loaded model with nobody left to ask it anything, which is the
whole failure this class is careful about everywhere else. Found by two
stand-in servers still running after a test run.
2026-08-01 20:09:00 +03:00
yusufipk 2cfbbb2d99 Transcribe and clean up on this machine, without installing anything first
whisper-server is started on --inference-path /v1/audio/transcriptions,
which is exactly the path api.py already builds for the hosted providers,
and llama-server answers /chat/completions the way OpenRouter does. So the
local half is one more base URL rather than a second code path: worker.py,
filetranscribe.py and meeting.py are untouched, and dictation, subtitles
and meetings all work here on the first try.

Three findings worth naming, none of them in the new code:

whisper.cpp cuts segments on tokens, which in Turkish lands inside a word
about as often as between two. Pasted raw that gives "akraba değ\niller.";
in a subtitle it gives a cue reading "değ". Whisper marks the start of a
word with a leading space, so a piece that does not begin with one
continues the word above it.

A small model will repeat the transcript until the context is full, and
every one of those tokens is a second of somebody waiting: measured at 206
seconds, and 25 with a ceiling on the reply. Hosted models are left alone,
where the same runaway is rare and a ceiling would cut the minutes short.

A server outlives SIGTERM and SIGKILL holding its model in memory. Signals
are now turned into an event Qt delivers, since Qt blocks in C where a
Python handler never runs, and a pid file lets the next start sweep up
what a SIGKILL left behind.

The minutes keep their own provider rather than following cleanup's. The
two jobs are not the same size: a 4B model here will strip the filler words
out of a dictation and will not write up an hour long meeting.

The suite runs offline now: a test that reaches the network says so instead
of quietly going there.
2026-08-01 20:00:35 +03:00
yusufipk c0b892f53c Fetch whisper.cpp and llama.cpp, and keep a server alive on each
Two programs, one treatment: ask GitHub what was released, take the build
this machine can run, check it against the sha256 the index published, then
fetch a model from Hugging Face the same way and keep one server up on a
port of its own.

Neither list is written into the source. A model catalogue in here means a
release of Dikte for every model somebody else publishes, and a pinned
version means one for every whisper.cpp release; both are somebody else's
news. The lists are read at the moment the settings window is opened and
cached for a few hours, and a cache that has gone stale still answers when
the network does not.

Nothing that lands on disk is trusted for having arrived, which matters
more here than usual because half of what is fetched is a program Dikte
then runs.
2026-08-01 20:00:21 +03:00
Yusuf İpek 37f045d308 Merge pull request #10 from yusufipk/worktree-claude-openai-support
Clean the transcript up on the subscription you already have
2026-08-01 19:36:14 +03:00
yusufipk 034c2bec7e Clean the transcript up on the subscription you already have
Cleanup was the one step with only one place to run. Speech to text has three
providers behind a setting and the agent has three behind another, but the
model that drops the "eee"s out of a sentence was always a request to
OpenRouter, which meant a second key on a machine that already pays for a model
and already hands whole dictations to it as commands. Claude Code and Codex can
rewrite a sentence as easily as they can put something in your calendar, and
now they may.

cleanup.py is where that choice lives, so worker, the file transcriber and the
meeting all ask the same question rather than each building the same OpenRouter
request. What comes out of a CLI that failed is a CleanupError, which is an
ApiError, because to the chain a cleanup that failed is a cleanup that failed
however it was run: the raw transcript is still pasted and the reason still
shows in the corner, unchanged.

Neither CLI is given anything it does not need for the job. No tools, no MCP
servers, no session to resume, and the home directory rather than wherever the
agent is pointed, since a project's instructions have opinions about how text
should be written and none of them are about this transcript. The transcript
goes in fenced the same way the OpenRouter call fences it, because it is
material rather than an instruction however much of it reads like one. Claude
takes the cleanup rules as its whole system prompt; Codex has no system prompt
of its own, so they ride in front of the text, and its answer is read from the
file it writes on the way out rather than from a stdout that also carries a
header, its thinking and a token count.

The cost is seconds. OpenRouter answers in about one, a CLI in six or seven,
because each one opens a whole session to do it. That is the trade the box
says out loud, and the default has not moved: OpenRouter cleans up until you
say otherwise.

Codex's two lowest thinking levels now ask for "low". "minimal" was its bottom
rung until the newer models replaced it with "none", and each of them answers
the other's word with a 400, which the agent has been quietly hitting too.

In the settings window the model box belongs to whoever is chosen rather than
meaning three different things in turn, since an OpenRouter id and a Claude
alias do not belong in the same field, and under it is the same "found it or
not" line the agent tab has. dikte doctor asks about the program instead of the
key when a CLI does the cleaning, and the history records which model actually
did it.
2026-08-01 19:30:55 +03:00
Yusuf İpek da8112179b Merge pull request #9 from yusufipk/cancel-shortcut-and-scripts
A key for discarding a recording, plus uninstall.sh and update.sh
2026-08-01 19:05:28 +03:00
yusufipk 698eacf33a Take the install back, and pull the next one in
install.sh has been the only half of its job that was written down. Removing
Dikte meant remembering a symlink, three desktop files, an autostart entry and
a handful of kglobalshortcutsrc keys, and updating meant a pull followed by
guessing which of those the new version had moved. Both are now scripts,
because both are lists somebody would otherwise keep in their head.

uninstall.sh removes what install.sh put down and stops there. What you have
written is yours: the settings and the dictations, the meetings and the
recordings survive a plain run, and --purge is the word that deletes them. It
prints what that would cost first, how many dictations, how many meetings, how
much audio, since a count is the thing that makes the sentence real, and
without a terminal to ask it refuses rather than assuming --yes. Only our own
symlink goes; a file of that name somebody else put there is left alone. The
shortcuts are handed back to `dikte shortcut remove` rather than unpicked from
KDE's file here, because that is the half that knows whether they went into
kglobalshortcutsrc or into GNOME's gsettings.

update.sh asks whether anything is waiting before it complains about anything
else, because an unfinished afternoon in the working tree is nobody's problem
on a day when nothing has been published. If something is waiting and you do
have edits of your own, it stops and says so rather than choosing for you. The
merge is --ff-only: an update is somebody else's commits arriving, never a
merge a script decided to make on your behalf.

Then install.sh runs again, since an update can add a dependency or move a
file, and it is told which keys you chose so that it puts those back instead of
its own defaults. They are read from the settings rather than from the
desktop's file, that being the one place they mean the same thing on KDE and on
GNOME, and a key you had turned off is passed back as the empty string rather
than quietly becoming the default again. Finally the running instance is
restarted, because it is still holding the code from before the pull.
2026-08-01 18:57:50 +03:00
yusufipk 3486892ec6 Give throwing a recording away a key of its own
Stopping a recording is the step there is no taking back. It is what sends the
audio off, and a moment later the sentence you did not mean to dictate is in
the clipboard and pasted into whatever window you were typing in. The tray menu
was the only way out, and by the time it is open the recording has already
gone. Discarding needed to be as quick as starting, which means a key.

Ctrl+Alt+Space rather than Escape: the combination the recording started with,
one modifier along, so the two are one gesture with a modifier between them.
Escape is the obvious choice and the wrong one, because it belongs to whichever
window has focus, and while you are talking something else usually has it. It
works on a dictation and on a command for the agent alike, since the one you
want to take back is the one that is running.

That made four global shortcuts, and four is the number at which three copies
of the same forty lines stop being a coincidence. The command line already had
a table of them; hotkey.py now holds it, and the settings window reads it too,
so a shortcut is a row rather than a combination box, two buttons, a status
label and three methods written out again. The window no longer takes the
commands to run as arguments either, because ipc.command_for already knows them
from the verb. Adding the fourth key is what this buys: one line in the table
and one call per row.

Both places a key can live are told about it. The listener catching the press
and the KDE shortcut arriving behind it go through the same echo guard the
toggle already had, so the two are one discard rather than two, and the tray
calls the inner method as it already did for the toggle. Ctrl+Space and
Ctrl+Alt+Space land on the same evdev key code, so there is a test for the
modifier matching that keeps them apart.

install.sh takes the second key as a second argument and refuses to register
two of the same. It hands both to `dikte shortcut install` rather than writing
kglobalshortcutsrc itself, which is what makes it work on GNOME, and what
finally puts the chosen key in the settings as well: the built-in listener
reads it from there, so a key written to only one of the two places was a key
that half worked.

Left empty in the settings window the discard key stays empty, unlike the
dictation shortcut which falls back to Ctrl+Space: a recording can always be
thrown away from the tray menu, so there is nothing to guarantee here.
2026-08-01 18:57:50 +03:00
Yusuf İpek 1e959178b4 Merge pull request #6 from muzafferemre06/feature/groq-transcription
Add Groq as a transcription provider
2026-08-01 18:32:46 +03:00
yusufipkandMuzaffer Emre d04efe235a Put the three providers in a table, and test the request rather than the list
Reworked on top of master. The Groq request itself needed nothing beyond a key,
a base URL and a model id, but two providers fit in an if and an else where
three do not, and each one was written out four times: in transcribe_target(),
in the key rows of the settings window, in save and in load.

config.TRANSCRIBERS holds them now, one row each: the name the user sees and
the three settings that keep its key, its endpoint and its model. The variable
an empty key falls back to is the name of its setting, shouted. The provider
box, save, load, `dikte models --provider` and `dikte test-key` all read that
table, so a fourth provider is a row and a default rather than a branch in five
files. The key field, its Test button and its answer line are built once and
the three signals became one that carries which key was asked about.

The tests moved into the files of the modules they cover and check what a
provider actually changes: that the request goes to api.groq.com with the right
model and fields, that the glossary now reaches everything except OpenRouter,
that a Groq error says Groq, and that the settings window carries the key and
the model there and back. GROQ_API_KEY is cleared for the test run like the
other two, so a developer who has one does not send a test to the network.

Co-authored-by: Muzaffer Emre <[email protected]>
2026-08-01 22:09:41 +07:00
yusufipk 22edd2f247 Merge master into feature/groq-transcription 2026-08-01 22:09:01 +07:00
yusufipkandfirat 3bd8c1ad27 Add a Mac as the third system, beside Wayland and X11
Dikte already chose its clipboard programs once instead of in every
function; macOS joins that table rather than adding a branch to each one.
A Mac copies through pbcopy and presses Cmd+V straight into CoreGraphics,
records through AVFoundation, and asks Carbon for its global shortcuts.

The three tables are paste.Desktop, audio.Sound, and the pair of
predicates in hotkey.py. Each reads sys.platform inside the chooser, so a
test can stand somewhere else: 697 of the 737 tests now run on any
machine, the Wayland and X11 halves included, and the suite passes whole
whichever system it is run on.

Two things a Mac does not have needed saying rather than pretending:
there is no shortcut registry to install into, so Settings offers no
Install button and the listener is the mechanism instead of a fallback;
and nothing is offered as the sound the speakers are playing, so a
meeting needs BlackHole or Loopback and says so. The KDE-only labels
around them were already wrong on GNOME, and now name whichever desktop
is there.

Co-authored-by: firat <[email protected]>
2026-08-01 21:28:58 +07:00
Yusuf İpek 676664ea74 Merge pull request #5 from muzafferemre06/feature/ubuntu-x11-gnome
Support GNOME X11 and PulseAudio without changing Wayland
2026-08-01 16:58:18 +03:00
yusufipk 5c429ab077 Ask parec for sound as it arrives, not two seconds of it at a time
Left alone, parec buffers about two seconds and then hands the lot over at
once. Measured against the chunk the level meter reads:

    parec, as it was          longest gap  2005 ms
    parec --latency-msec=64   longest gap    86 ms
    pw-record                 longest gap   129 ms

On a machine that has parec, which is every PulseAudio one and every PipeWire
one with the compatibility service, the waveform stood still and then jumped,
looking like a microphone that was not picking anything up. The recording
itself was fine, but the tail of one can be lost that way too: stop() ends the
process, and whatever is still held back goes with it.

The number asked for is the meter's own chunk, which is the unit the rest of
this file is measured in.
2026-08-01 20:52:43 +07:00
yusufipk 3ead6eb1b5 Merge remote-tracking branch 'origin/master' into feature/ubuntu-x11-gnome 2026-08-01 20:43:26 +07:00
yusufipk 45a064e545 Tell a recorder that died from one that was asked to stop
The pump now says when the capture ended with nothing captured, which is worth
saying: parec refusing the device looks like silence otherwise. But stop() ends
it the same way, so a recording shorter than 0.3 s raised that alarm first and
"Recording too short" second, sending the user after a sound server that is
fine. It follows the flag MeetingRecorder already carries for this.

test_desktop_compat.py moves into the files for the modules it covers, so a
test is where the next person looking at that module will find it.
2026-08-01 20:34:15 +07:00
yusufipk 1544cca15c Install the GNOME shortcut this already knew how to install
install_shortcut(), remove_shortcut(), shortcut_status() and desktop_name()
were added and nothing called them: the settings window and the command line
both still went straight to the KDE ones. On GNOME, pressing Install ran
kwriteconfig6, which is not there, and the three interface strings written for
this never appeared. README said the settings window installs a GNOME or KDE
shortcut, and it installed neither.

Both callers go through the chooser now, and the status line names whichever
desktop answered.
2026-08-01 20:32:07 +07:00
yusufipk 0e6ec3aea9 Choose the clipboard's programs once, instead of in every function
Which pair of tools a session uses was being asked inside copy(), read(),
copy_bytes() and press(), four times, in a conditional each. A third desktop
means a third branch in each of them, and macOS is already open as a pull
request doing exactly that in a different style.

Wayland and X11 are two entries at the top of the file now, each naming its
programs and how to build a key press from them. The functions below take
whichever entry the session gets. Adding a desktop is an entry and a line in
desktop(), and it touches nothing else.

Two things fall out of it. The key press is checked against the same table on
both: xdotool was being handed the shortcut as typed, so ctrl+f13 was refused
on Wayland and passed through on X11. And a missing tool names the package to
install again, which the shared message had dropped.

The tests say the promises once and run them against both desktops, so a third
one inherits the list rather than needing a copy of it.
2026-08-01 20:32:07 +07:00
yusufipk 3d4c29c853 Merge branch 'test-suite' into feature/ubuntu-x11-gnome 2026-08-01 20:15:22 +07:00
Yusuf İpek b3bb805593 Merge pull request #7 from yusufipk/test-suite
Test what the application does, before the pull requests land
2026-08-01 16:12:18 +03:00
yusufipk 80a4832818 Test what the application does, and run it on every pull request
570 tests over the standard library's unittest, so there is nothing to
install beyond the PyQt6 the application already needs. They reach neither
the network, the microphone, nor the real ~/.config/dikte: urllib is faked at
one function, the tools are faked at shutil.which, and every test is handed
its own config and data directory.

What they hold onto is what a change is most likely to move without meaning
to. The request each provider is sent, field by field. A settings window that
loads a value into a widget and writes it back, which is where a setting
added to one half and not the other is silently reset. The dictation chain
end to end: what is transcribed, what is pasted, what lands in the history,
and what happens to the audio afterwards. A config file written by an older
version. A meeting whose two channels heard the same sentence.

59 of them carry @linux_only, because they cover what Dikte is on this
desktop rather than what it does: PipeWire, wl-clipboard, ydotool, KDE's
shortcut file. The other 511 pass on any platform, and that line is worth
holding as the ports arrive.

CONTRIBUTING.md says how to run them, what support.py offers, and the three
things about this codebase that trip up a new test.
2026-08-01 20:06:47 +07:00
yusufipk 1b12ae0c2f Find a meeting by the date it was recorded, not only by its stem
A stem is all digits too, so "dikte meetings show 20260801" was read as a
position and found nothing: there is no twenty-millionth meeting. A number
counts back only while there are that many meetings to count back through,
and anything larger is a date somebody typed.
2026-08-01 20:06:35 +07:00
yusufipk 45779a6c50 Say which stock phrase was discarded, instead of crashing on it
A transcript the hallucination filter throws away is named in the message
that says so, and the placeholder it goes into is called {text}. So is the
first parameter of t(), which made the call two values for one argument and
a TypeError: the user was told "Unexpected error" rather than what happened.

Both strings are positional-only now, so no placeholder can ever collide with
them again.
2026-08-01 20:06:28 +07:00
yusufipk bb3b0bcb94 Give every window a verb, and answer what the terminal asks
The socket carried a bare word and said nothing back, so a script could press
the buttons but never learn what was said. A request is JSON now, it can ask to
be answered when the run ends rather than when it starts, and the reply carries
the transcript, the agent's answer, or why nothing happened. The verbs that do
not need the microphone run in the caller's own process, which is what makes
transcribing a file or reading a setting work over ssh.
2026-08-01 18:58:57 +07:00
Muzaffer Emre dd73735617 Add Groq transcription support 2026-07-31 18:48:21 +03:00
Muzaffer Emre d64ed8968a Add GNOME X11 and PulseAudio support 2026-07-31 18:47:49 +03:00
yusufipk da3dc3c908 Give an audio file its own cleanup rules, written for subtitles
Cleaning up a dictation and cleaning up a file are not the same job, and until
now they shared one prompt. A dictation is read afterwards, so dropping a
filler and tightening a sentence is a favour. A file becomes an SRT, and there
the same favour is damage: the viewer hears the words while the line is on
screen, so a word that was said and is not written is noticed, and a phrase
pulled onto the line above is on screen before it is spoken.

So the file path gets its own system prompt. It says what the text is and what
it is for, and it spends its room on the one repair only context can make: the
word the transcriber misheard. Speech models fail phonetically on names, and
somebody talking about Anthropic said "Claude", not "cloud". The lines stay
where they are, nothing is shortened, nothing is turned into an abbreviation,
and the filler words stay because they were said out loud.

The glossary and the timestamp rule are appended as before, so a name listed
under Cleanup rules still reaches this prompt, and a timestamped run still gets
told to leave the stamps alone. The dictation prompt is untouched, and so are
the meeting and agent paths.

Cleanup rules now has a tab each. An untouched prompt is still stored empty, so
switching the interface language keeps switching the prompt language with it.
2026-07-30 23:06:40 +07:00
yusufipk d05bab4f98 Wave the agent's progress away, and say how hard it should think
Two things a real command turned up.

A job can run for ten minutes. "Report on the front page" is not a question
with an answer a second later, and having the corner narrate every tool it
touches for those ten minutes is worse than saying nothing. So that indicator
can be clicked away. It is the only one that can: a dictation is over in
seconds, and an indicator that swallows a click meant for the window underneath
has to earn it. The work carries on; what is muted is the progress, not the
outcome, which shows up whether or not the box was sent away. A faint cross on
the right says the box can be clicked, because a feature nobody can see is not
one. The next run starts visible again.

Muting leaves the state alone rather than setting it to something hidden, so no
later repaint puts the box back on the screen behind its own back.

Thinking effort is a setting now, offered once rather than three times: how hard
to think is one thing to want, and only the rungs differ. Claude takes it as
--effort, Codex as a model_reasoning_effort override, OpenRouter in the
reasoning field it already understood for cleanup. A level a provider does not
have lands on the nearest one it does, so "maximum" is xhigh on Claude and high
on Codex rather than an error or a silent drop. Left alone, nothing is sent and
each model does what it would have done.
2026-07-28 19:03:03 +07:00
yusufipk e2551343e4 Run a dictation and a command to the agent at the same time
The two shared one state machine, which meant an agent thinking for a minute
was a minute in which nothing could be dictated. That is the wrong trade: the
work has nothing in common past the microphone, and the microphone is busy for
the seconds you are speaking, not the minute afterwards.

So they are two flows now, each with its own state, its own pipeline and its own
indicator. Either can be working while the other records. Only the recorder is
taken in turns, because there is one of it: whichever asks second is refused
while the first holds it, and the menu entry greys out to say so rather than
failing quietly. "Am I recording" is read off the two states rather than off the
recorder's owner, which outlives the recording by the moment it takes the audio
to arrive, and would otherwise report a microphone as busy after it was free.

Two indicators in one corner would sit on top of each other, so an indicator can
be told what it stacks on: it offsets by that one's height while it is showing
and drops back into the corner when it goes, which the animation timer notices
without anything having to tell it.

Pasting is now under a lock. It is three steps rather than one, read the
clipboard, write ours, press the key, and two runs finishing together would
paste one answer and restore the other's clipboard over it.

Calling off the agent is its own menu entry and its own command, instead of a
cancel that meant different things depending on what was running. The tray icon
still ends whichever recording is going.
2026-07-28 18:29:48 +07:00
yusufipk 590c6cbe1a Call the tab Agent, pick shortcuts from a list, and stop answering from memory
Four things the first use of it turned up.

The tab was called Claude while the setting inside it chooses between three
providers, two of which are not Claude. It is Agent now, and the messages that
pointed at it by name point at the new one.

The three global shortcuts were the only fields in the window you had to know
the syntax of to fill in, next to a paste key that has been a dropdown all
along. They are editable combo boxes now, sharing one shortlist of combinations
that are usually free. Typing your own still works; it is just no longer the
only way.

The instruction was read as discouraging the tools, which is the opposite of the
point. "Keep it short" is about the answer, and a model that reads it as "answer
off the top of your head" hands back a caveat about its training data instead of
the thing it was asked. Asked who won the 2026 World Cup it answered, twice, that
the tournament had not been played, on the 28th of July. So shortness is now
stated as being about the answer rather than the work, and writing a caveat is
named as the moment to go and look instead. Same question, same model, only the
prompt changed: no tool calls and a wrong answer before, a web search and the
right one after. It also appended its sources, which a reply being pasted into
someone's text field has no use for, so that is ruled out alongside the headings
and the bullet lists.

Clicking the tray icon during a Claude recording did nothing, because the icon
was wired to the dictation toggle and that one leaves the other mode's recording
alone on purpose. The shortcuts should stay tied to their own mode; the icon is
one button and now ends whichever recording is running.
2026-07-28 17:20:41 +07:00
yusufipk 01eea7a363 Let the command go to Codex or OpenRouter, not only Claude Code
Everything the last commit built assumed one agent was installed, which is a
poor assumption to bake into a dictation tool. So the provider is a setting, and
what it selects is one of three quite different things.

Claude Code and Codex are the same shape: a CLI, streaming JSONL, a session id
to resume, tools that reach the machine and whatever is connected to it. They
share the runner. What differs is spelled out where it differs, which is more
than the flag names: Codex has no system prompt to append, so the instruction
rides in front of the command with a rule between them; it confines its commands
in a sandbox rather than asking about them, so the permission setting is a
sandbox mode; and `-s` is not accepted by `exec resume`, so both settings go
through `-c` overrides, which are.

OpenRouter is the odd one and is meant to be. No tools, no files, no calendar:
it can say what the capital of Peru is and not what is in your diary, and the
settings box says so rather than letting it be discovered. It also has no
session to resume, so the conversation is kept here and resent, capped at 24
messages.

A stored conversation names the provider that made it, and is ignored by any
other: none of them can pick up another's thread, and a stale id would otherwise
fail every command until the timeout cleared it.

The interface calls the thing by its name, which in Turkish means the suffix has
to agree with it: Claude'a but Codex'e, Claude'u but Codex'i. A name dropped
into a sentence through t() cannot be inflected by that sentence, so it arrives
inflected, from a small table in i18n. English takes the name as it is and keeps
the preposition in the sentence.
2026-07-28 17:08:14 +07:00
yusufipk 0206303751 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.
2026-07-28 16:57:10 +07:00
yusufipk 352f7b6c22 Record a meeting from both sides, and write its minutes
Who said what is the hard part of a meeting transcript, and the usual answer
is to hand one mixed recording to a model and ask it to tell the voices apart.
That guess is wrong often enough to be worse than useless in minutes, where a
decision attributed to the wrong person is a decision nobody made.

So the question never reaches a model. ffmpeg records the microphone and the
default sink's monitor as one stereo stream, you on the left and everyone else
on the right, and one process reading both is what keeps them aligned over an
hour. Each channel is transcribed on its own and the two are interleaved on a
single timeline, so attribution is settled by the wire a voice arrived on.
What a microphone picks up from the speakers lands on both channels; our copy
is dropped when it overlaps theirs in time and says nearly the same thing.

The stream is written to disk as it arrives rather than held in memory, so
length costs nothing and a crash costs the tail instead of the whole meeting.
Every stage the run reaches is recorded in meetings.jsonl, so a failure while
summarising does not throw away the transcription of an hour of audio: the
retry reads the transcript back out of the document and picks up from there.
A run that dies keeps its recording whether or not audio is being kept.

The minutes model is configured on its own, under Settings, with its own
prompt, and it is told who was expected in the room so the names come out
spelled right. It is told outright that the transcript is a record of other
people talking, not instructions addressed to it.

The built-in listener now holds several bindings rather than one, and the KDE
side is parameterised by desktop id, so the meeting toggle gets a shortcut of
its own on the same footing as the dictation one.
2026-07-28 16:32:31 +07:00
yusufipk 0bdee07564 Stop the KDE shortcut and the built-in listener from doubling a press
The listener reads /dev/input and reacts the instant a key goes down. The
KDE shortcut answers the same press by launching a whole Python process,
which then talks over IPC, so its toggle lands a few hundred milliseconds
later. The 400 ms guard caught that echo only when the machine happened to
be quick, and otherwise the recording was started and stopped by one press:
"No speech detected".

Route the two apart. A toggle arriving from outside the process right
behind a listener trigger is that echo, and its lateness also proves the
KDE shortcut is live, which leaves the listener with nothing to do but
double every press. So retire it, remember that in the config, and say so
in the tray rather than changing behaviour silently.
2026-07-27 23:00:14 +07:00
yusufipk 32e0fb061d Let the cleanup model be told how hard to think 2026-07-26 23:14:06 +07:00
yusufipk e3e678a0f2 Apply the same filler rule to the English prompt
The Turkish prompt stopped naming filler words and started weighing what a
word does in its sentence, and the English one had the same flaw waiting:
"like" and "you know" were listed as words that carry no meaning, which they
are only half the time. A model reading that next to the MINIMUM
interference rule leaves them where they are.

It now reads like the Turkish one. Thinking sounds are their own line,
everything else is dropped when the sentence survives without it and kept
when it points at something or carries the clause, the listed words are
examples rather than a closed set, and doubt resolves towards dropping.

Measured over three dictated paragraphs written to mix both uses, same model
and only the prompt swapped: 36 candidates in the raw text, 17 surviving the
old prompt, 6 the new one, the survivors being real uses such as "works like
a charm" and "you know the one I mean". The old default is fingerprinted as
legacy alongside the Turkish one.
2026-07-26 22:51:02 +07:00
yusufipk f77fd44991 Judge a filler word by its job in the sentence, not by a list
The Turkish cleanup prompt listed "hani" among the filler words from the
first version on, and the model kept it anyway. The history file shows why:
in one and the same dictation "eee" goes and "hani" stays. There is nothing
ambiguous about a thinking sound, so it always goes, while "hani" is a real
word half the time ("hani su adam vardi ya"), and against the MINIMUM
interference the rest of the prompt asks for, the model resolves that doubt
by leaving it alone. Naming the word once more would not have changed that.

So the rule no longer turns on which word it is. Thinking sounds are their
own line, and everything else is measured by what it does in the sentence:
drop it if the meaning survives, keep it if it points at something or
carries the clause, and when in doubt drop it. The words that used to be the
rule are now examples, explicitly not a closed set, so the ones nobody
thought to list are judged the same way.

Measured over the six most recent dictations that carry such words, cleaned
with the same model and only the prompt swapped: 18 in the raw text, 17
surviving the old prompt, 6 the new one, and most of those six are genuine
uses. The old default is fingerprinted as legacy so that a settings window
still showing it does not write it back into the config.
2026-07-26 22:47:16 +07:00
yusufipk 37e360743d Save the transcript as subtitles too, not just as text
whisper-1's verbose response carries a start and an end for every segment,
and the file tab was reading only the start, to build the [mm:ss] prefix.
Keeping the end as well is all an SRT needs.

The text stays the authority on wording and the segments on timing; they
meet at that prefix, which the cleanup model is already told to leave alone.
So a transcript that went through cleanup still turns into properly timed
subtitles. A line whose stamp matches no segment runs until the next line
starts, a line with no stamp at all joins the cue above it, and an end that
would run into the next cue is trimmed back.

The button is dead until a timestamped run finishes, because without
timestamps there are no segments to time anything with.
2026-07-26 22:33:32 +07:00
yusufipk c3f22f0794 Stop the indicator from making the window behind it flinch
Hiding the indicator unmapped its window, so KWin tore that window down and
built a new one for the next dictation. On a tiled desktop the compositor
repainted whatever sat underneath as it went, and the terminal behind
visibly flinched every time a transcript landed. Measured with a KWin
script: five show-and-hide rounds raised five windowAdded and five
windowRemoved events, where there is now one of each for the life of the
process.

So the window stays mapped and paints nothing while idle. That has to be a
real repaint rather than zero opacity: with the animation timer stopped
nothing else damages the surface, and the stale frame sat on the screen
until something unrelated, moving the mouse, made the compositor redraw it.

The timer was also left running after the indicator went away, ticking
thirty times a second over a window nobody could see. It stops now, and the
finished message lingers for two seconds rather than one.
2026-07-26 12:54:23 +07:00
yusufipk 1dc7eceee4 Drop the provider note, and reshoot the API tab
The line under Speech to text repeated the provider box directly above it,
so nothing was lost by removing it.
2026-07-25 23:05:40 +07:00