Commit Graph
54 Commits
Author SHA1 Message Date
yusufipek fedb4fe5c0 Let the new tests run on a Windows box and on a small machine
Two ways the tests were standing on this machine rather than on the one
they meant to describe.

Windows has no os.sysconf at all, and mock.patch.object insists the
attribute exists before it will replace it, so four tests failed at the
patch rather than in the body. `create=True` is what lets them stand
somewhere that has no such function, which is the case the code under test
already handles two lines down and which is checked on its own.

And the publisher order follows the memory on purpose: a runner with 7 GB
in it puts the two Gemma 4 rows last and is right to. The three tests that
read an order now say which machine they are standing on instead of
assuming the one that ran them has room for everything.
2026-09-05 11:31:03 +03:00
yusufipek 08fc2e4a9d Group the model lists and say which row this machine should take
The two local model boxes handed over a flat list sorted by size and left
every choice in it to the reader. For whisper that interleaved the models:
large-v3-turbo-q5_0 landed between the two medium quantisations, half a
screen from the turbo model it is a copy of. For cleanup it was forty
repository ids, half of which answer with nothing at all because what they
publish is split across files or larger than the cap, and an empty box read
as though the click had not registered.

Now each box says what the machine is, groups the list by model, and marks
the row to take:

- whisper rows are grouped by model, with the quantisations and the
  English-only files under the model they are a copy of, and every row says
  its bit depth rather than leaving q5_1 and Q4_K_M and BF16 to be decoded.
- the recommendation follows the machine. Under 4 GB it is small-q5_1;
  with a graphics interface and 15 GB it is large-v3-q5_0, which is worth
  about two and a half points of word error in the languages that are not
  English; in between it is turbo, and a processor build where the Vulkan
  one belongs is not counted as a card.
- a row larger than half the memory less a gigabyte says it is too big.
- the publisher box holds the five suggestions until the switch beside it
  is turned on, and a line under it says in words what the chosen one is.
- a publisher that answers with nothing says why instead of going blank.
- the draft heads (dflash, dspark, eagle3) are no longer offered as models,
  and neither are the base models that sit beside their tuned twin.
2026-09-05 11:12:42 +03:00
yusufipek 4ae44720c8 Merge master, and let one function pick the archive
master grew _pick_asset() while this branch grew a second answer to the
same question inside install_program(). Two functions deciding which
archive this machine wants is one too many, so the managed build is
asked for at the top of the picker: Dikte's own Vulkan whisper-server
first where the machine is one it is built for, then upstream's newest,
then the nightly pointer, then the newest release that carries a build.

install_program() is back to master's three lines and reads which of the
two landed off the asset name.
2026-09-05 10:06:38 +03:00
Yusuf İpek 74c37c0112 Merge pull request #73 from yusufipk/llama-nightly-and-model-box
Find the llama.cpp nightly builds, and keep the model box honest
2026-09-05 09:51:24 +03:00
yusufipek fff9cd1c55 Keep the publisher and the model boxes saying the same thing
Changing the publisher left the model box untouched: the old selection was carried over, added back as "not downloaded" and selected again, so a model the new repository does not publish could be saved against it. The selection is now only carried within the publisher it was made in, every keystroke in the publisher box no longer starts its own request, and a list that comes back for a publisher that is no longer chosen is dropped rather than answering the wrong one.

The status line grew two things it could not say before. A row rebuilt from a name alone carries no file to fetch, and the Download button stayed lit over it doing nothing; those rows now say the publisher does not offer the model, and the button is out. A model that is here while the program above it is not no longer reads "Ready", which is what had people asking why nothing transcribed.
2026-09-05 09:47:42 +03:00
yusufipek 00a5283adb Let a downloaded program be downloaded again
The button disappeared the moment anything landed, and nothing else on
the window asks for that download. So a machine that got the processor
build before its graphics driver was installed can never be given the
Vulkan one, and nobody can pick up a newer whisper.cpp either: both of
those are the same missing control.

It reads "Download again" once a copy is here, and stays hidden while a
system one is on the PATH, because that is the copy that would run.
2026-09-05 09:45:00 +03:00
yusufipek 59180b8eff Say when the processor build landed instead of the Vulkan one
The install falls back to upstream's CPU archive whenever Dikte's own
release, the file in it, or its reviewed digest is not there, and until
the package is published by hand that is every download. It happened
without a word: the window said "Downloaded, version v1.9.3." either
way, and a graphics card sitting idle looks exactly like one being used.

The install record now carries which of the two builds landed, written
only where both were on offer, and the settings window says so on the
line that already reports the version.
2026-09-05 09:16:59 +03:00
yusufipek eda1398a2b Call the OpenRouter subtitle model the audio file model
Timestamps are a file transcription option, so the model box is named
after the file rather than the format it ends up in.
2026-09-02 12:43:07 +03:00
yusufipek 6e307bd8d0 Let OpenRouter subtitles use a chosen model instead of whisper-1
A timestamped run on OpenRouter always asked openai/whisper-1 for the
segments, whatever model was picked for plain transcription. Not every
model there returns segment times, so the one to use is now its own
setting, openrouter_subtitle_model, shown in the speech-to-text box only
when OpenRouter is the provider. Empty keeps the old whisper-1 fallback.

Target carries the choice as subtitle_model and timestamp_model() reads
it; the other providers are unchanged.
2026-09-02 12:41:43 +03:00
yusufipek 22a73bd939 Merge master into the OpenCode Go branch
Master grew Google AI Studio and Antigravity as providers, a doctor that
names each provider's own key, and settings that fetch every hosted
model list as the window opens. OpenCode Go is folded into each: its
name joins SERVICES and the doctor's key table, its key row sits beside
Google's, its Fetch button follows the per-provider pattern Google's
uses, and _load_hosted_models fetches its catalog at open when a key is
on file, filling the cleanup and agent boxes alike.
2026-08-27 16:27:57 +03:00
Yusuf İpek 5fd32d105f Merge pull request #59 from Oztturk/gemini-and-agy-cleanup
Clean up on Google AI Studio, or on Antigravity
2026-08-27 16:14:34 +03:00
yusufipekandClaude Fable 5 d331ccb877 Fetch every model list at open, without being asked
Codex's list already arrived on its own, because reading a cache on disk
costs nothing. The other three lists were behind a Fetch button, which
meant the built-in ones aged in front of anyone who never pressed it. Now
OpenRouter's and Google's lists are fetched when the settings window
opens, and Antigravity's comes off `agy models`, which prints one
id-tab-name line per model and answers over the network in a couple of
seconds; all three run off the interface thread.

Nobody asked, so nothing is reported: a failure changes nothing on
screen, the built-in lists stay, and the Fetch buttons remain both the
retry and the place an error is worth explaining. A provider whose key
has not been given is not called at all, so opening Settings is not by
itself a request to two vendors.

Also drops an import the merge had left in twice.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-27 16:12:18 +03:00
yusufipek fa3d72f5a7 Fetch OpenCode Go's catalog as the settings window opens
Codex already refreshes its boxes from the source at open, so the
built-in list is never the whole truth for longer than a window takes to
build. OpenCode Go now gets the same courtesy: one request to /models on
a background thread, both of its boxes refilled with the picked model
kept, skipped entirely when there is no key to send.
2026-08-27 16:04:30 +03:00
Yusuf İpek 7b7df1df62 Merge pull request #60 from AydoganCan60/feature/display-selection
Add display selection for recording indicator
2026-08-27 15:54:30 +03:00
yusufipek 65055eeb56 Give OpenCode Go a reachable Fetch model list button
The one button lived in the OpenRouter model row, which leaves the
screen whenever another provider is chosen, so the OpenCode fetch path
could never be clicked. The OpenCode row now carries its own button into
the same handler, the row as a whole is what hides, and the fetched list
lands in the box it belongs to without touching the meeting box.
2026-08-27 15:53:44 +03:00
yusufipek ccc8ba596e Merge master into the OpenCode Go branch
Codex now asks itself for its model list, doctor grew ready flags and a
per-provider line, and the Groq key joined the masked ones; the OpenCode
additions are folded into each. The no-CLI cleanup test follows the
fake_run to fake_cli rename.
2026-08-27 15:51:49 +03:00
yusufipek 1df2d03056 Merge master into the Gemini and Antigravity branch
Both sides rewrote the doctor: master rebuilt it around ready flags so a
fully local setup stops reading as broken, while this branch taught it
that Google has a key of its own and that the local model has neither a
key nor a program. Kept master's structure and folded the branch in: the
two hosted providers answer for their keys, a CLI for its program, and
the JSON keeps both the provider-aware "key" and master's "ready".

SECRET_KEYS gained groq on master and gemini here; the resolution keeps
all four. _conclude was also changed by both: master stopped matching
stderr wording and asks _API_TROUBLE instead, this branch renamed its
last parameter to the provider's short name so a session is stored under
the name it is read back by; the rename now rides on master's body.
codex_models() and the settings loaders landed beside the new Gemini
ones, so both stay. The new cleanup tests called the CLI fake by its old
name, fake_run, which master had renamed to fake_cli.
2026-08-27 15:50:48 +03:00
Yusuf İpek 9736bd0ecd Merge pull request #54 from yusufipk/codex-model-list
Ask Codex itself which models it offers
2026-08-27 15:31:24 +03:00
Yusuf İpek d896680a4f Merge pull request #50 from huseyin-emre-tigci/single-instance
Survive every way a dictation was being lost
2026-08-27 15:28:05 +03:00
yusufipek 17a55e1efc Merge master into the reliability branch
The paste block was rewritten by both sides: master taught press() to
put the remembered application back in front (focus), this branch moved
the history write ahead of the paste and stopped restoring the old
clipboard over a transcript the key press refused. Kept this branch's
order and error handling, and handed press() the focus it now takes.
2026-08-27 15:23:41 +03:00
yusufipek a3bb5ed29b Keep the rebuild away from a window a thread is still writing to
deleteLater destroyed a window whose model boxes a daemon thread was
still reporting into: a running download died with a RuntimeError, the
.part stayed on disk and the UI said nothing. Dropping the reference is
how the ordinary close path lets a window go, and the closures a thread
holds keep the object alive until it is done, so the rebuild now does
the same. While the transcriber or either model box is working the
rebuild is skipped altogether; _built_language keeps the old language,
so the next quiet save asks for it by itself.

The new window is also placed and turned to the old tab before it is
shown, so it no longer comes up at the default size and jumps. And
tests/test_ui.py now says that a save with a changed language emits
language_changed, and one without does not.
2026-08-27 15:13:54 +03:00
Aydogan e6208418f9 Add display selection for recording indicator 2026-08-27 07:46:34 +03:00
oztturkandClaude Opus 5 1812461612 Clean up on Google AI Studio, or on Antigravity
OpenRouter's free tier rate-limits and carries no free Gemini model, and
cleaning up through Claude Code costs a fixed few seconds because it opens
a whole CLI session to drop three "uh"s. Google's own free tier suits a
short, frequent request, and its OpenAI-compatible endpoint answers
/chat/completions, so cleanup there is one request and the same code path
OpenRouter already takes.

The one thing that is not shared is the thinking level. Google reads
OpenAI's flat reasoning_effort rather than OpenRouter's object, and "none"
is how thinking is turned off, so it is sent rather than skipped: a Flash
model left to think spends exactly the second this provider was chosen to
save. Its top two rungs land on "high", which is as far as Google goes.

Speech to text stays where it was. That endpoint has no
/audio/transcriptions behind it, audio only goes in as base64 inside a
chat message, and what comes back has none of the segment times a subtitle
file or a meeting transcript is built out of.

Antigravity joins as well, on cleanup and as an agent. It is a CLI like the
other two and costs the same session, so it is here for people who already
pay for it rather than as an answer to the speed. It takes neither an empty
tool list nor a read-only sandbox, and cleanup.py now says so plainly
instead of implying parity; what it gets is a project of its own, the home
directory, and its slash commands off.

Three things were already wrong and are fixed on the way past, because the
new providers walk the same paths: doctor raised KeyError on the local
model, whose executable is ""; the history recorded Claude's model whoever
answered; and every agent row read "asked Claude".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-26 16:23:04 +03:00
sudoeren 3c336e7178 Offer OpenCode Go in the settings window
A key row under Keys, a model box in the cleanup tab and a box in the
agent tab, each with its own model list of the models OpenCode Go
serves over /chat/completions. Fetch model list reads whichever cleanup
provider is on screen, and the Turkish strings cover the new rows.
2026-08-25 21:05:21 +03:00
yusufipek e9488a51a4 Ask Codex itself which models it offers
The Codex model boxes carried a hand-written list, which was already out of
date. The settings window now asks `codex debug models` for the catalog the
CLI's own picker reads, off the interface thread, and refills both boxes with
it; the built-in list is only what is on screen until Codex answers, or when
it is not installed at all.
2026-08-25 14:42:19 +03:00
Gökhan 901e57e4d0 Merge master into the macOS front branch 2026-08-25 00:16:20 +03:00
huseyin-emre-tigciandClaude Fable 5 e8147f49f8 Say true things in every language, and stop paying twice
doctor judged the local provider by the API key it does not use, so a
fully local machine always saw a red mark, and it crashed outright with
local cleanup picked; both lines now ask readiness. config list printed
the Groq key in plaintext while masking the other two. The one subprocess
decoded with the locale codepage gets its UTF-8 back, so a Turkish
filename cannot hang a file transcription, and a redirected stdout on
Windows replaces what it cannot encode instead of failing after the work
succeeded. meeting-cancel stops advertising a --wait the server never
honoured. "you" and "bye" leave the hallucination list: people dictate
them. The minutes stage failing no longer burns the transcription
checkpoint, and an untouched meeting-length dial no longer rewrites a
value the command line set in seconds. A prompt box compared against the
wrong language's default after a switch no longer fossilizes the old
default as a custom prompt.

The 67 strings of the local-model box, the whole first-run screen of the
shipped default, get their Turkish. The hub cache moves to the platform's
cache directory instead of ~/.cache on every system; the old directory is
a few orphaned kilobytes with a six-hour shelf life. The last NO_WINDOW
spellings collapse into the constant paths already carries, one
windowed-executable lookup, one session-file reader, one install-record
reader, one download progress signal carrying its destination, and the
KDE conflict scan loses the branch its other branch already covered.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 23:17:22 +03:00
yusufipek 084c48151d Say when a newer release is out, and stop there
A check on the releases page once a day: at start, on a timer while Dikte
runs, from the General tab on demand, and from `dikte update` at a terminal.
What it finds goes in the tray menu and in one notification per version, and
opens the release page.

Nothing is downloaded and nothing is installed. The four downloads are
installed four different ways and three of those belong to the platform: a
Mac bundle cannot rewrite itself while it is running, the Windows setup has
an uninstall entry of its own, an AppImage is a file kept wherever its owner
keeps it, and a checkout is updated with git. Being wrong about any one of
them means an installation somebody has to repair by hand.

Versions are compared by their numbers alone. A build off master carries the
released number with its commit after it, and that build is ahead of the
release it names rather than behind it; read as a version suffix, every
nightly would be told to go back to a release it had already passed.

The clock lives in its own file rather than in the settings, since a check
runs while the settings window may be open and a background write into
config.json is what would overwrite whatever it holds.
2026-08-22 10:34:59 +03:00
firat 0be64c2ef9 Wait for macOS focus restoration to land 2026-08-18 18:26:15 +02:00
GökhanandClaude Opus 5 45b18722be Keep the front where the dictation started on macOS
Recording goes through ffmpeg's avfoundation input, and opening a capture
session there brings the process that did it to the front. ffmpeg is a
child of Dikte with no bundle of its own, so macOS credits the move to
Dikte: the window the user was dictating into goes inactive, its caret
stops, its title bar greys out, and the Cmd+V at the end of the run lands
somewhere other than the document it was meant for. Measured with a
TextEdit document in front:

    press the shortcut          front = TextEdit
    recorder.start returns      front = TextEdit
    89 ms later                 front = Dikte

Nothing about the capture session can be asked not to do this. Starting
ffmpeg in its own session, and clearing __CFBundleIdentifier from its
environment, were both tried and both measured to make no difference, so
it is undone instead: the application in front is noted before the
indicator goes up, and a short watch puts it back the moment Dikte takes
the front. Measured at 99 ms from the moment it is taken, against the
title bar staying grey for the whole dictation before. All three ways in
do it, dictation, agent and meeting, since all three open the same
capture.

The indicator had a share of the same problem and needed AppKit for it
too, so mac_window.py carries both. An NSPanel is hidden by the system
the moment its application stops being the active one, which for a
dictation indicator is immediately, and ordering one to the front brings
its application with it unless it carries the nonactivating style bit.
Neither is reachable through Qt.

The runtime is loaded in _appkit() rather than at import, the way paste.py
loads its frameworks in _macos_api(), so the module imports on a machine
with no AppKit and the tests below run there as well: 52 new tests, none
of them skipped anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-16 17:37:31 +03:00
yusufipek 180ddac37e Let the Windows job read the merged suite
The merge put a Windows runner under tests master wrote for two systems,
and 21 of them fell over on it. Two were the quoting: `command_for` goes
through shlex now, so a Windows path comes back in quotes, and the two
tests that read the command as a string were reading it as a Linux one.
They ask through the same join instead.

The other nineteen are `integrate.py`, which writes the menu entry and
the login item a downloaded build installs for itself. There are two
downloads, an AppImage and a disk image, so the module has a Linux half
and a macOS half and nothing a Windows host would run: its tests hand
the fake home over in $HOME, which Windows does not read, and compare
paths that start at the root. They carry `@posix_only`, which comes off
again the day there is a Windows build to integrate.
2026-08-16 15:54:44 +03:00
yusufipek b186f7fde2 Merge master: the modules moved into a package
Every file this branch touches moved into dikte/, so the merge is mostly the
rename following the edits. What needed a hand:

hotkey.py: master replaced the _macos()/_gnome() pair with one backend()
chooser, and this branch had added _windows() to the pair. Windows is a fifth
value of the chooser now, and everything that used to ask "macOS or Windows?"
asks backend() instead. The key is held by the running process there, so
installs_shortcuts() and shortcut_needs_restart() are both false for it, and
desktop_name() says Windows.

install.ps1 and the Windows README name dikte/__main__.py, the entry point the
Linux and macOS installers were pointed at in the same commit. The Start Menu
entry, the autostart entry and the dikte.cmd shim all come off one $entry
variable.

settings_ui.py: the shortcut tab now has a Windows sentence of its own, with
the Turkish for it. Falling through to the branch master wrote for a desktop
with no registry would have told a Windows user to check /dev/input. Nothing
covers that branch: there is no Windows Settings test class, the way there is
one for macOS.

CONTRIBUTING: the chooser it names is backend() now, and the test count is the
merged one, 1067 of 1110 running anywhere.
2026-08-16 14:10:28 +03:00
yusufipek 2f59029261 Put the modules in a package and the scripts in a folder
Twenty-two files at the top of the tree was the first thing anybody saw of
this repository. They are one package now, imported relatively, and the three
scripts that are not the front door moved under scripts/. install.sh stays
where the README has always said it is.

What starts the application is dikte/__main__.py: python3 -m dikte runs it,
and so does naming the file, which is what the launcher symlink, both .desktop
files, the macOS bundle and every registered shortcut do. Run by path there is
no package around it, so it puts the checkout on sys.path itself.

The installers now keep the keys you chose when they are given none, which is
what an update is: update.sh no longer has to read them out and pass them back.
An updater from before this commit cannot read them at all, so the one thing it
can say, the default key with an empty discard key, is read as "nothing was
asked for" rather than obeyed. That guard can go once nobody is updating across
this commit.
2026-08-16 14:01:01 +03:00
yusufipek 1f5ea70fa6 Stop telling every desktop that it is KDE
Only GNOME was recognised, and everything else was handed to KWin. On i3,
XFCE, Cinnamon, MATE, sway and the rest, Dikte wrote an entry into
kglobalshortcutsrc that nothing reads, called the session KDE, and
promised that the keys would work after the next login. They never did.

There is no backend to write for any of them. The /dev/input listener is
already desktop-agnostic, so those sessions are the case macOS has always
been: no registry, nothing to install, nothing to remove, and the
combination held by the running process. One backend() function decides
which of the four this session has, and the name shown, the status read
back, what Install writes, what Settings explains and what the installer
promises are all taken from it, so they cannot disagree.

A desktop now only counts when the program that writes its registry is
there too. A GNOME session without gsettings and a Plasma one without
kwriteconfig6 fall to the listener rather than to a file, which is also
how Plasma 5 stops erroring on a kwriteconfig6 it never had.

What Settings shows on those desktops is the truth: no Install button, no
KWin, no listener checkbox (it is the mechanism, not a choice), what
reading /dev/input costs, that the focused application sees the keys too,
and the command to bind if you would rather your desktop owned them. The
evdev listener records what it is listening for the way the Carbon one
does, so the status line has something to say there at all.

Closes #28
2026-08-16 13:41:27 +03:00
yusufipek 0e767454f3 Let a recording be held while something else happens
A phone call in the middle of a dictation left two choices: send what
was said so far off to be transcribed, or throw it away. Both end the
sentence you were in the middle of. Now the recording can be held: the
microphone stays ours, what was said before the pause stays in the
buffer, and what is said during it is dropped.

The capture program keeps running and keeps handing blocks over, which
are read and thrown away rather than left in the pipe. Stopping it
instead would mean asking the sound server for the device again on the
way back, and that is the one moment another application can take it: a
recording would be lost to the phone call it was paused for.

The clock stops with it. Paused time is time the recording does not
have, so the indicator and the length limit both go by what was
actually captured, and a five minute limit is not spent waiting.

The indicator says so as well, since a pulsing dot, moving bars and a
counting clock otherwise all say the words are still going in: the
ribbon freezes where the pause found it and turns amber behind two
bars. The tray menu holds and resumes it, `dikte pause` does, and so
does a global shortcut, which starts empty because holding a recording
is not something a keyboard has a habit for.

Dictation and a command to the agent both, whichever is recording. A
meeting is left out: it writes to a file as it goes and keeps two
streams aligned itself, and neither of those wants a hole in it.
2026-08-16 13:06:36 +03:00
Yusuf İpekandSeyit Gokce 6397f6c403 Merge pull request #31 from seyitgkc/fix/settings-window-height
Keep the settings window inside the screen

Co-authored-by: Seyit Gokce <[email protected]>
2026-08-16 12:53:03 +03:00
yusufipek 5547f85848 Keep the wheel off the boxes and a floor under the window
Two things the scrolling tabs brought with them.

A combo box and a spin box read the wheel as a change of value, and Qt
hands them the focus before delivering it. Now that every tab scrolls,
rolling down the API tab with the pointer over the model box picks a
different model on the way past, and Save writes it down. The boxes take
the focus by click or by tab only, and a wheel that arrives at one
without the focus is refused rather than swallowed, so it carries on up
to the scroll area and the page moves instead.

A tab that scrolls also asks for no height of its own, which left
nothing to stop the window being dragged down to a tab bar and half a
button. It has a floor now, and the floor never asks for more room than
the screen was just found to have.
2026-08-16 12:09:21 +03:00
yusufipek 8c62795b8b Give Windows devices an identifier, and ask ffmpeg for them once
Three things about the dshow backend, all of them found by reading rather
than by running, so all three want checking on a real Windows machine.

The device listing is parsed in both of the shapes ffmpeg has printed it in:
newer builds mark every device `(audio)` or `(video)`, older ones print a
heading and no marks, and only the first was read. Each pattern is anchored at
both ends now, so the error lines the command ends with, which quote the device
name it was told to look for, are no longer read as a device of that name.

What is stored for a device is the alternative name under it rather than the
friendly one. A laptop with a headset plugged in has two microphones called the
same thing, and `audio=Microphone` reaches the first of them whichever one was
picked; the alternative name is unique. The friendly name stays what is shown,
which is what the (id, description) pair in these lists has always been for.

An unset microphone meant "the first one listed", and the listing costs an
ffmpeg of its own, so every press of the key paid for a process before the
recording started. The last listing is remembered instead, and opening Settings
or running `dikte devices` takes a fresh one.

And a fourth thing, which is about what the interface says rather than what it
does: whether the far side of a meeting can be captured at all is now an entry
in `audio.Sound` instead of being read off an empty device list. The two are not
the same answer. An empty list on Linux means pactl is not installed, which a
user can go and fix; False on Windows means there is no such device and no
driver that would add one. The Meeting tab says so under the empty box, and
starting a meeting says it instead of sending somebody to Settings to pick from
a list that will never have anything in it.
2026-08-16 10:28:43 +03:00
yusufipek 191eef8f8d Merge master into the Windows port
Three of the four collisions were the same one: master moved the directory
rule into paths.py while this branch was adding a Windows case to the copy in
config.py and the second copy in ggml.py. The case moves to paths.py with the
rest of it, and the directories test moves to tests/test_paths.py where master
put its neighbours.

The fourth is MeetingRecorder, which now starts a process per capture device.
Windows keeps its two lines there: no console window for either process, and
a stop that terminates rather than sending a signal the platform does not have.
2026-08-16 10:14:37 +03:00
Can Soykan Yılmaz 44cfb76652 Give a Mac an installer, an app bundle and a menu bar icon it can see
The macOS backends were already here: CoreAudio capture through ffmpeg,
pbcopy and CoreGraphics, Carbon hotkeys, the paths under ~/Library. What
was missing was everything that installs them, so install.sh hands over to
install-mac.sh on Darwin rather than growing a branch per line: the XDG
directories, the .desktop files and the shortcut registry mean nothing
there, and an application is a bundle rather than a path. The bundle
carries a copy of the interpreter, because macOS files the microphone and
Accessibility permissions against the process that asks, and a launcher
running Homebrew's python3 would have asked as python3 and shared the
grant with everything else on that interpreter. It is signed ad-hoc so a
reinstall is the same application rather than two more dialogs, and it
says so plainly when a brew upgrade has moved the tree it needs.
uninstall.sh and update.sh follow it.

The tray icon was invisible: QIcon.fromTheme wants a freedesktop icon
theme and hands back a null icon without one, which in a menu bar is the
whole interface gone. trayicon.py draws the three shapes as template
images, so they follow the menu bar into dark mode, and the bundle's icon
comes off the same glyph rather than a binary in the repository. Linux
keeps its own icons; these are used only where the theme has nothing.

paths.py is the fix that was never about a Mac. config.py imports ggml.py,
so ggml.py could not ask it where the data goes; each worked it out for
itself and only one of them knew about macOS. Settings went to ~/Library
while several gigabytes of models went to ~/.local/share, which is not a
place a Mac user looks and not a place uninstall.sh --purge would have
deleted from.

Ctrl+Space is the input-source switch there and Cmd+Space is Spotlight, so
the default is Ctrl+Option+Space, and hotkey.default_combo is the one
place that difference lives. The first paste asks for Accessibility with
kAXTrustedCheckOptionPrompt, which is what creates the row to switch on;
asking the other way opens a pane Dikte is not listed in. `dikte shortcut
status` asks the running instance, since the combination is held by that
process and by nothing else.

Local speech to text is the one piece a Mac builds by hand. whisper.cpp
publishes no macOS binary and Homebrew's is configured with
WHISPER_BUILD_SERVER=OFF, so it installs whisper-cli and not the server
Dikte talks to; program_path already takes a whisper-server off the PATH
or out of Settings, so the answer is the one a Linux distribution gets,
and the README carries the cmake line. CI grows a macOS job on 3.11 and
3.13, the only place the Carbon and CoreGraphics libraries have to be
there to be opened.

Written and tested on macOS 27.0 arm64. Two things are still unverified on
a Mac: the paste end to end, which waits on the Accessibility toggle, and
a meeting recording, which needs a loopback driver.
2026-08-15 15:56:27 +03:00
Seyit Gokce f7fa37b4b7 Keep the settings window inside the screen
Only two tabs scrolled, so the rest handed their full content height to
the window as a minimum. The API tab alone asked for 668px, which put the
window's minimum at 756px: taller than a laptop screen has room for once
the menu bar and dock have taken theirs, and unshrinkable, so Save sat off
the bottom edge. Every tab scrolls now, and the opening size is clamped to
what the screen actually offers.

The program path in the local model box was clipped to one line for a
related reason: it shares a form row with a button, and the row is
measured before its width is known. WrappedLabel re-measures against the
width it ends up with and claims that height back.
2026-08-14 17:21:37 -04:00
huseyin-emre-tigciandClaude Fable 5 3436e6b426 Add Windows support
Windows joins the three systems as its own entry in each table: DirectShow
through ffmpeg for capture, the Win32 clipboard and SendInput for the paste,
RegisterHotKey for the global shortcut, and the whisper.cpp and llama.cpp
Windows zips (the OpenBLAS whisper build, which transcribes about twice as
fast on a plain CPU). Settings go to APPDATA, data to LOCALAPPDATA, and
install.ps1 adds the Start Menu entry, the dikte command and an optional
autostart. Meetings are not supported yet: Windows offers nothing to record
the far side from.

Porting surfaced three fixes that were not Windows specific:

- A stopped or overlong download tried to delete its .part file while still
  holding it open, which Windows refuses. The unlinks now wait for the handle.
- The CLI transcribed files without handing the local servers their settings
  first, so a local provider failed with "no model downloaded" wherever the
  GUI had not run in the same process.
- The audio content types are pinned instead of asked of the registry, which
  answers differently machine to machine.

One fix is Windows specific but sits in shared code: shutdown() does not end
a blocked recv there, so stopping a request also closes the socket handle.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 16:53:21 +03:00
yusufipk 77b26e76be Stop the indicator from swallowing clicks in its corner
WA_TransparentForMouseEvents does nothing for a top-level window: Qt
takes the click and then drops it, so it still never reaches whatever is
underneath. Since the window stays mapped while idle, that turned its
corner of the screen into a dead zone for good. Qt::WindowTransparentForInput
is the one that leaves the window without an input region at all.

The dismissable one has to keep taking clicks, and the flag is read once
when the window is created, so it shrinks to a point while concealed
instead. Resizing keeps the surface alive, which is the whole reason
concealing does not simply hide it.
2026-08-10 15:59:35 +03: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 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 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 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