Skip to content

Push-to-talk, a native macOS engine, and the placement work behind them - #1

Draft
samartomar wants to merge 33 commits into
mainfrom
push-to-talk
Draft

Push-to-talk, a native macOS engine, and the placement work behind them#1
samartomar wants to merge 33 commits into
mainfrom
push-to-talk

Conversation

@samartomar

Copy link
Copy Markdown
Owner

Draft, opened to get CI on it — in particular the new macos-latest leg that compiles native/flow_stt.swift, which is the one thing that cannot be checked on the machine it was written on.

What is here

  • Push-to-talk. ctrl+win press warms the models and opens the mic, the hold is the utterance, the release sends. Both gestures ship and Settings ▸ Chord switches between them live.
  • The same gesture without a hotkey — hold the pill. That is the Mac path: no WH_KEYBOARD_LL, nothing granted but the microphone.
  • A native macOS engine. Apple's on-device recogniser behind a small Swift helper, so a machine that cannot reach huggingface.co needs no model files at all. --engine auto will not switch a machine that is working.
  • Placement moved to bottom centre of the monitor under the pointer, which turned up a real bug: the work area was read once from SystemParametersInfoW, which only answers for the primary monitor.
  • Aqua window fixes reported from a real Mac — a pill with close and maximize buttons on it, sitting under the Dock.

What CI is being asked

The suite (2100 tests) already passes on Windows and macOS locally. The new question is the Swift: it is written on Windows and has never been compiled. The first version had a hard error in it — top-level statements outside main.swift — and three more were found by re-reading rather than by building.

Compile and link only. Running it needs a granted Speech Recognition permission and an on-device model that arrives when a human enables Dictation, neither of which a headless runner has.

samartomar and others added 6 commits August 30, 2026 23:34
…ut a hotkey

The chord was a toggle that fired one word on release. It is push-to-talk now:
press warms the models and opens the microphone, the hold is the utterance, and
the release stops capture and pastes what was said. Both gestures ship and
Settings > Chord switches between them while Flow runs, because they are good at
different things - a hold needs no decision about when you are finished, and a
toggle is the only one of the two that survives a paragraph or hands that cannot
hold two keys down.

The same gesture is on the pill itself, which is the part that matters off
Windows: `Chord` is a WH_KEYBOARD_LL hook and there is no such thing on a Mac, so
Lite has never had push-to-talk. It does not need a hotkey to. Hold the pill,
speak, let go - one button now carries three gestures, judged on what you did:
a quick click toggles, a 300 ms hold talks, motion drags. That fixes something
older on the way past: `_toggle` was bound to the button *press*, so every drag
of the pill used to toggle listening.

Five things can send one utterance - the chip, the hotkey, the spoken trigger,
converse's countdown, and now a release waiting on a decode. A hold owns one
send and whoever gets there first has it, enforced at the single point all five
share rather than five guards that would drift. Clear cancels a pending paste,
a mode switch drops it, the toggle hotkey mid-hold commits rather than letting
`pause()` refuse the decode it just asked for, and a hold is refused while the
hand editor is open instead of recording nothing.

Two ceilings, neither hypothetical: a hold whose release never arrives stops
itself after two minutes, and a paste that never lands gives up after fifteen
seconds and says where the words are. Both keep what was said.

Placement moved to bottom centre of the monitor under the pointer, taken from
FluidVoice's positionWindow - centred on the physical display, stood on the work
area. `"place": "corner"` restores the old bottom right. Doing it turned up a
real bug: the work area was read once at launch from SystemParametersInfoW,
which only ever answers for the primary monitor, so on a two-monitor desk every
window Flow drew could land on the screen you were not looking at. Hidden panels
park past every display instead of unmapping, so a hold shows the draft in the
time a window takes to move.

The meter blooms from its centre rather than scrolling a history past, which is
FluidVoice's BottomWaveformView arithmetic - envelope, 0.55 response curve and
rounded caps.

Re-measured the body column at the shipped 420 px rather than the 380 it was
taken at: 62 characters a line, not 56, and the tail is 1750 characters for the
same 28 laid-out lines. Idle unload went 5 min to 30 - five minutes is inside the
gaps of a working session, so the common case was a reload in the middle of
somebody's first sentence back rather than memory reclaimed from somebody gone.
The comment defending it said "the 141 MB model" while two tiers were resident;
it is ~605 MB.

Carries the per-app prompt sets already in the tree - refine.py, the apps table
and tests/test_apps.py - because profile.py, README.md and guide.md hold both
and splitting them needs hunk surgery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng it

Two things a Mac reported in one breath: the pill had close and maximize
buttons on it, and it sat under the Dock.

The frame is Aqua's window class, not the redirect flag. `overrideredirect(True)`
strips the title bar on Windows and X11, but Tk maps a Toplevel to an NSWindow
whose style mask comes from the window class, so on a Mac it did nothing
visible. `MacWindowStyle plain noActivates` is the supported way to ask, and it
carries the second half too: `noActivates` is Aqua's WS_EX_NOACTIVATE, without
which clicking the pill pulls focus off whatever is being dictated into - the
defect that made every paste land in the wrong window on Windows, already fixed
once and no reason to ship again.

The Dock is a work area Flow was not asking for. `_work_area` degrades to the
whole screen off Windows, so bottom-centre placement stood the stack on the very
bottom edge - behind the Dock on macOS, behind the panel on a bottom-taskbar
Linux. `wm_maxsize` looked like the answer and is useless: on Windows it reports
the full screen even with a taskbar present, wrong in exactly the way this is
meant to fix. What works is maximising a transparent probe window and reading
back where the window manager put it, since it has to honour its own panels to
do that - exact against SystemParametersInfoW on left, right and bottom.
Measured once and cached, because `_sync_monitor` asks every frame.

`top` is taken as reported and is a title bar low. `winfo_rooty` is the client
area and the frame inset differs between a normal window and a maximised one -
measuring the inset and subtracting it made the answer worse, not better (-8
against a true 0). It feeds one thing, the ceiling in `bottom_centre`, where
being conservative costs nothing.

scripts/mac_probe.py prints every number Tk will give for this, next to what
Flow computes from them, so the next platform question is a measurement rather
than a third guess. It is what found the `wm_maxsize` dead end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…as nothing to run

faster-whisper's weights are on HuggingFace and nowhere else official - SYSTRAN's
GitHub ships the library, not the models - so a network that blocks that one host
leaves copying 138 MB by hand onto every machine. macOS already has an on-device
recogniser whose models belong to the OS. Using it ends the transport problem
rather than routing around it.

A subprocess, not a binding. The dependency budget is three and PyObjC is not one
of them, and Flow already shells out to codex and claude, so a process that reads
audio and writes text is a shape this app has. It also puts every Objective-C API
behind a pipe, where a crash is an exit code instead of a dead interpreter. The
helper is compiled on first use rather than shipped: an unsigned prebuilt binary
is a Gatekeeper prompt with a worse story than the source it came from.

The seam was already there. `Transcriber` is one method wide, `load`/`unload`/
`take_confidence`/`take_drops` are read through getattr, and clean.py has a
documented narrow path for an engine that cannot report `no_speech_prob`.
Somebody planned for this.

--engine auto will not switch a machine that is working, and that is the whole
rule. Apple's recogniser is a *different* engine, not a spare one: no
`no_speech_prob`, so the hallucination filter drops to the narrow check; one
quality tier where Whisper has two; no biasing, so the re-listen that rescues a
mis-heard command comes back unbiased. Changing what Flow hears without being
asked would be the wrong default, so auto reaches for it in exactly one case -
the Whisper models are not on this machine and cannot be fetched. The startup
line always says which engine ran and why.

The presence check asks with the hub forced offline and puts the variable back,
because it is asked precisely when the network may be unusable and must not
become the download it is testing for.

Not compiled here - this machine has no Swift. flow_stt.swift is written against
SFSpeechRecognizer with requiresOnDeviceRecognition, and `--file` transcribes one
recording so Apple's quality can be judged before any of it is wired into a
dictation loop. The Python half is tested: the wire format is asserted against
the bytes actually written, since the far side reads them with
load(as: UInt32.self).littleEndian and a disagreement there is a silent
mis-decode rather than an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… for consent

The macOS decoder is written on a machine with no Swift toolchain, so until now
the compiler was a person on a laptop pasting errors back. The first version
shipped with a hard one in it: Swift allows top-level statements only in a file
called `main.swift`, and this is not one. A macos-latest leg is the fix - the
same runner the suite already uses, doing the one thing that machine can do and
this one cannot.

Three more found by reading it again with that in mind, two of which would have
been runtime rather than a build failure:

  * The recogniser delivers its callbacks to the main queue by default, and
    `transcribe` blocks the calling thread waiting for one. On the main thread
    that is a deadlock, not a slow decode. It gets its own OperationQueue.
  * Every read out of `Data` is unaligned now. `Data` carries no alignment
    guarantee and `load(as:)` requires one, so the aligned form traps on some
    buffers and not others - the worst way to find out.
  * `update(from:count:)` is recent; `memcpy` is not, and this has to build on
    whatever toolchain a machine happens to have.

Arguments are parsed before `authorize()`. A typo should not raise a permission
prompt, and CI has to reach the usage line on a runner where no permission could
ever be granted - which is what lets the leg prove the binary links and runs at
all rather than merely compiles.

Compile-and-link only, and the comment says why that is the honest limit:
actually running it needs a granted Speech Recognition permission and an
on-device model that only arrives when a human enables Dictation. Faking either
would make the leg green about something it never checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tform

CI compiled the helper and said two things.

`@main` and a single-file executable are mutually exclusive: Swift parses one
file as a script, and the attribute cannot be used in a module with top-level
code. The compiler's own note names the fix, so `-parse-as-library` goes in all
four places that build this - the file's own header, flow/native.py, the CI leg
and the guide. Renaming to main.swift was the alternative; the flag keeps the
file named after what it is.

`withUnsafeMutableBytes` hands back whatever its closure returns, here
`copyBytes`' byte count, and the leg builds with -warnings-as-errors on purpose:
this file is edited by people who cannot run it, so a warning is the only signal
that reaches them before a user does.

Three of mine in the macOS suite, all the same mistake - a test reading the
platform it happens to be on rather than pinning one. `ctypes.windll` does not
exist off Windows, so patching it needs create=True; and the two that assert the
native engine's *refusal* were pinned to win32, because on a macOS runner there
is nothing to refuse and they would go build a real binary instead. A test that
means one thing on Windows and another on macOS is only half run wherever it
passes.

The macOS leg was already red on main before this branch - test_lite and
test_main reach ctypes.WinDLL, and the font-metric check measures 10 where
Windows measures 7. Those are not touched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… builds it

CI found a real one, and it was not about CI: the macOS leg went from 35 seconds
on main to 643 on this branch. Every launch on a Mac without Whisper models was
compiling Swift and then sitting on `--probe` until it timed out, because the
probe waits for an authorization dialog that a headless machine never answers.

A user's first launch would have done exactly the same thing. A full minute of
nothing before a pill appears, on a machine that asked for none of it, to decide
whether to offer an engine it might not use.

So `--engine auto` passes compile_if_missing=False and a ten second probe: it
uses the helper if it is already built and says how to build it if not.
`--engine native` still compiles, because somebody who typed it has asked for
the wait. Once it has been run once, auto finds the binary from then on.

A timeout is now named as the dialog it is rather than as
"probe failed: TimeoutExpired", which is true and useless when the fix is a
click.

And every one of those reasons reaches `say()`, which writes to a cp437 console
- so the em dashes in them were a launch that died on its own explanation. That
is how this one was found; there is a test over the module's string literals now
so the next one is caught here rather than by a user on Windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
samartomar and others added 22 commits August 30, 2026 23:42
Two guesses at Aqua have been spent already. The pill came back with a title bar
and three traffic lights while the panels above it were correctly bare, and the
difference between them is that `Pill` *is* the root window - `class Pill(tk.Tk)`
- where Bubble and ConversationCard are Toplevels. Aqua builds the root's NSWindow
before Tk can restyle it, so what works for a Toplevel need not work for `.`.

Five windows, five techniques, labelled on screen. The answer is which ones have
no title bar, which is a thing a person can see and this machine cannot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`MacWindowStyle plain` is not frameless, and believing it was is what put a title
bar and three traffic lights on the pill. `plain` is a window *class*. A Mac
running scripts/mac_frame_probe.py settled it: a Toplevel given only the style
comes up decorated, one given only overrideredirect(True) comes up bare, and the
call reports `ok` either way - it succeeds at doing the wrong thing.

So this was a regression I introduced. Before it, `_no_activate` returned False
off Windows and did nothing, and every window was bare because overrideredirect
had already done the work. Applying the style afterwards put the frame back on.

overrideredirect strips the frame here as it does everywhere else, and the style
call stays for `noActivates` alone - the half that keeps a click on the pill from
pulling focus off whatever is being dictated into. It is asked for first and the
redirect re-asserted after, so the class can never win.

The root needs it knocked off twice. Aqua builds the NSWindow when the window is
first mapped and a redirect asked for afterwards does not restyle what already
exists, which is why Bubble and ConversationCard were bare while the pill - which
*is* the root, `class Pill(tk.Tk)` - was not. Withdrawing and remapping forces the
rebuild, and only for a window already on screen: the panels are withdrawn when
`_no_activate` runs over all three, and deiconifying them would put two empty
surfaces in front of the user.

Also from that probe: the Mac is on Tk 9.0, not 8.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Mac put the pill in the top-left corner, and the reason was a sanity check that
only asked "is this a rectangle".

`state("zoomed")` on Aqua does not raise and does not maximise either - it is
accepted and ignored. So the probe stayed the 200x120 it was asked for, at +80+80,
and (80, 80, 280, 200) passed a test that wanted a positive rectangle no bigger
than the screen. The stack was then placed against a work area 200 px wide, which
on a 1512-wide display is the top-left corner. Exactly where it was found.

The test is not "is this a rectangle" but "did the window manager do the thing",
and it is asked two ways because either alone has a hole. A window that never grew
past what it asked for is the direct evidence. A rectangle far smaller than the
display catches a window manager that grew it a little and stopped - a real work
area is the screen minus a Dock or a taskbar, nowhere near half of it.

Still exact against SystemParametersInfoW on Windows, where zoomed does work.

Also: the remap that takes the frame off the root now puts the geometry back. A
window that comes back from a withdraw has been placed by whoever remapped it,
not by whoever positioned it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…end went

Clicking Send stopped working on a Mac, and it was the second half of the same
mistake as the title bar. `MacWindowStyle plain noActivates` asks Aqua for a
window that never activates - and a window that never activates does not take
clicks either.

The app already said so. `_menu` borrows the foreground on Windows precisely
because a WS_EX_NOACTIVATE window would get no input for its popup, and the
comment there states that Lite needs none of that: "`_no_activate` cannot take,
so the window is in the activation chain like any other and the popup gets its
input the ordinary way." That was an invariant, and asking Aqua for the
equivalent broke it.

So `_no_activate` refuses off Windows again, as it did before any of this, and
the frame is handled where it always belonged - beside the `overrideredirect`
call in `_shell_window`. `_mac_reframe` withdraws and remaps a window that is
already on screen, which is what makes the redirect reach an NSWindow Aqua had
already built. That is the whole of the root-window fix; nothing asks for a
window class any more.

The test for it asserts over the source rather than a mock, because the mistake
was *making the call*, and a mock cannot notice a call that is no longer there.
The name still appears in prose explaining why it is not used - those comments
are the point, so what is checked is `::tk::unsupported::`, the only form that
can do damage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…reated

A Mac showed the window and then hid it. That was the withdraw-and-remap cycle
added to "force Aqua to rebuild the NSWindow" - and it existed only because
MacWindowStyle had put a frame back on, which is a thing that call does and
overrideredirect had never failed to prevent.

Re-reading the probe with that in mind says so plainly. Window 4, the style asked
for on a *mapped* window, came up decorated. Window 2, the same style asked for
while the window was withdrawn, came up bare. The app was case 4: overrideredirect
early, style later, on a window long since mapped. The root was never the problem
and `class Pill(tk.Tk)` was a red herring.

So both attempts are gone and what is left is the line that had been doing the
work all along. Three commits to arrive back at one call, with two ways of making
it worse now written down.

The tests assert the absence rather than a mechanism, which is the honest shape
for this: there is nothing new to exercise, only two things that must not come
back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…one round trip

Diagnosing this platform from a Windows machine has been costing three messages
a question - send a photo, now the log, now run the other probe - and a phone
photo of a screen cannot answer "is it 24 px above the Dock or 240".

`scripts/mac_report.py` drives the real windows, screenshots them, renders the
numbers beside them and writes one PNG carrying both. Platform and Tk build, what
each work-area method answers, where the stack is therefore placed, how far off
centre it is, whether the native engine is ready.

A neutral backdrop goes behind it first. `shots.py` does that to keep captures
clean; here it is also a privacy line, because the whole point of this file is
that the image gets sent to somebody and a full-screen grab of a working machine
carries every window that happened to be open on it.

The crop is read off the windows rather than reserved with a margin. The first
version guessed 320 px above the pill and 60 below, which cropped in the taskbar
and cut off the pill's own bottom edge - which is exactly where a title bar or a
broken dock seam shows.

A black capture is named as the missing Screen Recording permission it is, rather
than looking like a bug in Flow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…utput

The quickest way to answer a question about this platform has turned out to be
somebody's agent running one command and pasting what it printed. Asking that for
Pillow and a Screen Recording grant to print twelve numbers is the report getting
in the way of being read, so --text skips the capture, the backdrop and the image
entirely.

Pillow moved to a lazy import for it. The numbers were always most of what the
picture was carrying.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…whole screen

The maximise probe that finds the taskbar exactly on Windows is accepted and ignored on
Aqua, so _tk_work_area fell back to the whole screen and the pill was placed 24 px above
878 - inside a 69 px Dock. Rather than guess a fourth time at this platform, this prints
wm maxsize (which Tk's Aqua port answers from NSScreen visibleFrame), where a window
asked for +0+0 actually lands, and fullscreen as a control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ores it

A Mac reported _tk_work_area() answering with the whole 1352x878 screen, identical to
_work_area(). state("zoomed") had neither raised nor maximised, so the fallback did not
fall back: bottom_centre stood the pill 24 px above 878 and it lived inside a 69 px
Dock. The close-up in the report was a picture of Dock icons.

wm maxsize is the call that knows, and only here - Tk's Aqua port answers it from
NSScreen visibleFrame. On Windows it reports the whole screen with a taskbar present,
which is why the maximise probe exists at all. It gives no origin, so the top comes from
where a window asked for +0+0 actually lands, and the bottom - the edge the pill stands
on - is top + height.

Every measurement is shape-checked and the path returns None unless all of them hold, so
if this platform surprises us again the worst case is exactly the old behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ht to check them against

scripts/mac_area_probe.py on a 14-inch MacBook Pro, Tk 9.0.3, 1352x878: wm maxsize
1352x736, a window asked for +0+0 landing at y 58, and zoomed answering
(80, 108, 280, 228) - the 200x120 probe still at the +80+80 it was asked for, never
maximised, no error. So the Dock's top edge is 58 + 736 = 794.

Both of those are Tk asking Tk, and three earlier guesses at this platform failed for
want of a second source. defaults read com.apple.dock tilesize on the same machine says
69, and 69 plus Apple's padding is the 84 px this leaves below 794. That agreement is
now a test.

The invented fixtures are replaced by the measured ones throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Transcribed from a photo of the terminal as 736, which the same output disproves on the
line above - lost: 143 h against an 878 screen is 735, and 58 + 735 is the 793 the probe
printed. The Dock is 85 px, not 84, which is if anything the better agreement with the
69 px tilesize the OS reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… lite mode

mac_report.py died on a Mac with bad attribute "-transparentcolor", raised by Tk before
a window had been drawn. -transparentcolor and -toolwindow exist on one platform and are
fatal on the others, and _shell_window guarded them on lite alone - safe only because
__main__ forces lite off Windows, so the two could never come apart.

They came apart the first time something other than __main__ built a Pill. An invariant
a caller has to know about is one a caller can miss, and this one is cheap to enforce
where it is actually true. mac_report now builds the lite window the app builds, which
is the one worth measuring anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ll is bare

The Aqua branch fired and answered 821 where the probe says 793 - 28 px too low, one
title bar, and the pill went off the Dock and straight back onto it.

wm maxsize is a maximum *content* size, so it is short by whatever decoration the window
that answered it wears: 735 from a titled probe against 763 from the overrideredirect
pill, on the same display. The first version took the origin from a probe and the size
from the caller's window, which are not the same window, and counted the title bar twice.

Now one probe answers all three - its own title bar from a y clear of any menu bar, the
menu bar from +0+0 less that title bar, and the frame height from maxsize plus it - so
the decoration appears on both sides and cancels. A bare window and a titled one must
now reach the same answer, which is a test.

Windows corroborates the arithmetic from the other direction: a 31 px title bar and a
0 px menu bar, there being none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from a Mac: click the app you want to dictate into and Flow's window vanishes,
and clicking Send does nothing. Those are one fault - a borderless overrideredirect
NSWindow is ordered out when its app stops being frontmost, and a click on a background
app's window is spent activating that app rather than pressing what is under it.

macOS answers this with a nonactivating NSPanel, which is what FluidVoice's overlay is.
Tk 9 exposes it as wm attributes -class and -stylemask - named by Flow's own crash, when
Tk refused -transparentcolor and listed what this build does accept.

The probe asks for a nonsense value first, because Tk answers that by enumerating the
legal ones, so a run is informative even if every variant below is wrong. It records
what survived the background on its own and logs which buttons a click reached, so the
answer comes back as pasted text rather than a judgement call or a photograph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first run answered a question it was not asked. Two variants had overrideredirect and
two did not, and the two without were both the ones a click reached and the ones still on
screen after clicking another app. overrideredirect on Aqua makes a window deaf and
fugitive at once, and it is the one line Flow uses on every window it owns. The NSPanel
class it was written to test never applied - cannot change the class after the mac window
is created, even withdrawn - so it was never the variable.

That leaves what overrideredirect was for: taking the title bar off. Tk's complaint about
a nonsense style bit listed the real ones, and a mask without the titled bit is a
borderless window that was never made deaf.

The title bar is now measured rather than looked at - ask for a known y, and how far below
it the client area lands is the decoration - so five of the six answers come back in a
paste. winfo_viewable is no longer trusted for the sixth: it reported all four windows
healthy while two were off the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two faults reported from a Mac - click the app you want to dictate into and Flow's window
vanishes, and clicking Send does nothing - and one cause. Six variants on screen split on
exactly this line: every window without overrideredirect kept its place when another app
came forward and had its button reached by a click, and every window with it was deaf and
gone. Which is a fair description of what it means - a window the window manager has been
told to stop managing.

A style mask is the set of bits an NSWindow is built from and titled is the one that puts
a title bar on, so a mask with no bits is bare with nothing else given away. Measured at
0 px of decoration against the control's 28, and its button was reached from the
background.

The NSPanel route is closed and unneeded: Tk answers cannot change the class after the
mac window is created even for a window never mapped, and Toplevel(class_=...) names a Tk
class rather than an NSWindow one. Nonactivating is about not stealing focus, and these
windows never take it.

Tk 8.6 on a Mac falls back to the old line rather than getting a frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refinement latency, reported from a Mac where Kiro's default model was slow and failing
on retry. Two levers, both read out of each CLI's own --help on a machine that has all
three rather than written from memory: --model on all of codex, claude and kiro-cli, and
--effort (low, medium, high, xhigh, max) on the last two. codex has no effort flag - its
only route is a config key that does not appear in its help - so it is not given one.

Effort defaults to low, deliberately. These calls are a rewrite, not a reasoning problem,
and the user is watching a spinner between finishing a sentence and having their words.

tuned() returns a new Cli rather than threading two arguments through four call layers:
Cli is frozen and its argv is a tuple, so everything downstream - resolve, _invoke,
_clean, the pill's marker - goes on working unchanged. The flags land after the
subcommand and before codex's trailing -, the positional saying the prompt is on stdin,
which a flag appended at the end would be read as the value of. Fallbacks are tuned too:
reverting to a CLI's own defaults the moment the first candidate failed would be slowest
exactly when the user is already waiting longest.

Model names cannot be discovered - no CLI will list them - and Flow has no text field to
type one into, so --cli-model carries a name in once and the menu is built from the names
that have been used. Effort is a menu from the start, being enumerable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The owner's verdict after every window fault was fixed: it seems on mac there is no
actual send it copies in clipboard that is making this no useful on mac. Which was right.
The whole idea is to speak into the window you are already working in, and now press
Cmd-V yourself is the step Flow exists to remove - so all of the window work led to a
clipboard and stopped.

inject_mac is inject.py's opposite number, satisfying the same two functions on_send is
written against. No SendInput and no window handle exist here: the text goes to the
pasteboard with pbcopy and System Events types Cmd-V into whatever is frontmost. It does
not need to aim, because Flow's windows are built without the titled bit and never take
focus - which is why the window work had to come first.

lite stopped being the same question as can-Flow-paste, and the two are now separate:
lite is hotkeys and window handles, and on_send is keyed off whether an injector was
imported. A Mac is Lite in every other respect and pastes.

osascript rather than CGEvent, deliberately: the compiled route is faster and is where
this should end up, but it is also an entitlement question and a build step, and both
would have to be right before anybody could try it once. This needs no dependency and
asks the OS for the same permission.

The permission is what will actually bite - Accessibility, granted to the terminal rather
than to Flow - so it is detected by error number and answered with the exact path through
System Settings, said at startup rather than at the first failed paste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The owner's verdict after the paste landed: window being re-draw and adjusting it self
... multiple windows size is problem and it's same bad shape on mac + windows ... sore to
eyes. Three separate motions, all of them removed.

The pill was PILL_W while nothing was docked and the panel's width while something was,
so it jumped 205 -> 420 the instant a draft appeared and back when it went. pill_w now
answers the panel width unconditionally, read from the constant rather than through a
window that may not exist yet.

The bubble and the card sized themselves to their contents on every render. The shots
show what that cost: a two-line partial made a 140 px panel and a three-line draft a 215
px one, so the top edge moved while you were still speaking. Both are PANEL_H now and the
body elides into it, which is machinery that already existed for the old ceiling. The
golden geometry table says it best - every row is the same 420x184 across a draft that
differs by fifty times its length.

Two arithmetic traps came with it, both found by looking at the shots rather than by
reading: the bubble capped its body at BODY_MAX_H unless frozen, and the card sized its
answer against the whole desktop, so both drew content for a window far taller than the
one they had. The card clipped its own agent label doing it.

_float_up keeps the fade and loses the 18 px rise. R14 wanted an appearance the eye could
follow when the window also changed size on every render; with the shape fixed the
movement was the only thing left moving.

184 px was chosen by looking. At 236 a two-line partial left a hole in the middle of the
window, which reads worse than the resizing it replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The structure FluidVoice's BottomOverlayView has and Flow did not: text and the control
row inside a single rounded shell. Flow drew two operating-system windows kept adjacent
by hand, and the seam between them was visible in every screenshot.

Bubble and ConversationCard are tk.Frame now, not tk.Toplevel - bands placed at (0,0)
inside the pill's window. Their drawing code is canvas-local and did not change; what
went is the window around it. Only eleven window-only calls existed between them, which
is why this was tractable at all.

Deleted: _sync_dock's width negotiation, both repositions' above-or-below decision,
_docked_above, park() on the panels, _float_up, and the ordering rule saying whichever
ran first left the other with nothing to do. All of it existed to make two windows look
like one. _sync_shell is what is left: a height, and a bottom edge that never moves.

The foot is the anchor, and that is the whole of the controls staying where they are. A
panel opening grows the window upward, so Send, the meter and the chip row stay at the
pixel they were at. Growing downward or centring the growth would move every control on
the surface every time a draft appeared.

Two defects found by looking rather than by reading. The row's seam overpaint cleared
rows 0..4 while its inner ring is drawn at y=4, so a second hairline stood 3 px under the
divider - invisible before, because a 1 px window gap was already drawing a line there.
Measured out of the shot: two RING rows at y=627 and y=642, now one. And band_h had to
come off the pill: the panels only ever had to fit the work area while the pill fitted it
separately, and sharing a window makes them one sum - a 200 px display put a 224 px shell
32 px past the bottom of it.

39 bubble tests and both dock classes asserted where one window went relative to another,
which is not a question any more; they are replaced by TestTheShellIsOneWindow and
TestTheBubbleIsABandInThePillsWindow, which assert the properties that survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A FluidVoice demo, read frame by frame, settled an argument I had got wrong. Its overlay's
bottom edge is at y=554 in every frame of thirteen seconds - idle, two lines, two lines,
three lines - and the box is snug around the text in every one of them. It never holds
empty space, and at the end it is gone.

So the fixed PANEL_H was stricter than the thing it was copying. It stopped the motion and
paid for it with a hole in the middle of the window, which is the same complaint the
resizing caused wearing different clothes. What must not move is the foot, not the height.

PANEL_H becomes PANEL_MAX_H, a ceiling. The band is sized from its content and snapped up
to a whole body line by _settled_h. That snap is what replaces FluidVoice's 80 ms
debounce: sizing to content is right, and a timer cancelled from a render loop that runs
thirty times a second is a thing to get wrong. A height that can only change when the text
gains or loses a line changes a handful of times an utterance by construction.

The shots caught a regression the tests did not: _sync_shell asked band_h() rather than
the band's actual height, so a 130 px band sat in a 224 px shell with the row floating
54 px below it - the detached-boxes look the merge exists to end.

Measured on the running app: as a draft grows the shell steps 170 -> 204 -> 221 px while
the foot stays at 648 in every frame.

Three tests flipped back to asserting growth, which is what they asserted before the fixed
height and is right again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The need, in the owner's words: there are times where i wanted to dictate but at the same
time i wanted to see but i don't want it to keep it on my screen.

Parking the window is the easy half and the dangerous one. A Flow with no window and no
icon is a process that cannot be reached, configured or quit except through Task Manager,
which is invariant 4 wearing a different hat: hidden must not mean gone. So the icon is
registered first and its answer is believed - no icon, no hiding, and the bubble says why.
That refusal is the test this file exists for.

Win32 through ctypes, no new dependency, R16 holds at three. Shell_NotifyIconW needs a
window to send clicks to and Tk will not give us one, so flow/tray.py creates its own
message-only window and pumps its own loop on its own thread. Nothing in that module
touches Tk: the window procedure puts strings on a queue and the UI drains it from
_frame, beside the hotkeys and for the same reason.

The chord still works while hidden - the hook is global and does not care what is on
screen - and _talk_start brings the window back before anything else, because a hold that
showed nothing would be an open microphone with no way to tell.

ctypes had to be told the signatures. Without them every call is assumed to return c_int
and a 64-bit HWND does not fit: CreateWindowExW failed with int too long to convert,
inside a thread nobody was watching.

Also renames the gestures for the wording the owner asked for by name: push to talk is a
thing people already know, and the label used to describe only the mechanics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
samartomar and others added 5 commits August 31, 2026 13:26
…d would not hide

Reported: "I'm not able to move this around on my screen ... it stays on the center
always", and "when I try to click on hide to tray it did not hide".

Both are mine, from merging the two windows.

_sync_shell asked _placed() for x and for the foot on every frame, so a drag was undone
before the hand had left the mouse. _sync_dock never had the fault because it only
recomputed x when the width changed, which was rare; recomputing unconditionally is what
the merge introduced. It now keeps where the window *is* and clamps it, and _placed stays
the answer at startup and on a monitor change, which is where _sync_monitor asks it.

Hiding failed twice over. park(self) reads win.width, which the panels have and the pill
did not, so the call went to tk.Misc.__getattr__ and looked for a Tcl command. And even
parked, _sync_shell put the window straight back on the next frame - it runs thirty times
a second and won every one of them.

Hiding now remembers where the window was, as (x, foot) rather than (x, y): the shell is
anchored by its bottom edge, so a panel that was open at hide time and closed by the time
it comes back must not move the controls.

Verified on the running app: a dragged x of 40 survives every subsequent frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two screenshots of "panel size: larger" showed a wide band above a row that had kept its
old width - two boxes of different widths stacked in one window, which is the exact look
merging them was meant to end.

apply_panel_width rebinds BUBBLE_W while Flow is running, so the width changes with
nothing else changing beside it. _sync_shell compared x, y and the height and not the
width, so the canvas was never re-placed and _docked_w - which is what _draw measures the
row against - kept the value it was built with. The old _sync_dock compared the width and
nothing else, so the merge managed to lose the one comparison that mattered here.

Verified on the running app across regular, large, larger and back: window, row, band and
_docked_w now agree at 420, 520, 640, 420.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Right-clicking the tray icon showed the menu and then did nothing. user32 exports
PostMessageA and PostMessageW and no bare PostMessage - the undecorated name is a C macro
- so ctypes raised AttributeError: function 'PostMessage' not found, from inside the tray
thread, after the menu had been chosen from and before anything acted on the choice.

Nothing in the suite could have caught it, because nothing named the function until
somebody clicked. So the suite names them all now: a test walks tray.py's own source for
every user32 call it makes and asks Windows whether each one is really there. Sixteen
symbols, all present, and it would have failed the moment the typo was written.

The click handler also stops anything escaping into Windows. It called us; an exception
unwinding into its stack is undefined at best, and a tray that quietly stops answering is
the failure this file exists to prevent - so it is caught and printed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"loading the model" used to be the first thing a fresh Flow said back - in the bubble,
while the user was already talking. The load lands inside that first utterance rather
than in front of it: first partial 1 230 ms against ~570 ms for the four behind it. The
chord's press-down has warmed the models since push-to-talk shipped, which covers the
second use and never the first.

warm() is called after the pill is built and before the loop runs, so the window is on
screen while the disk does its work rather than after it. It is single-flight and loads
on a thread of its own, so nothing at startup waits on it - measured at 0 ms to return,
with the model loaded 1.6 s later.

--no-warm keeps the old behaviour, for a launcher that starts with the machine, where
paying a model load at login is the wrong trade, and for measuring the cold path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked for by name - "Dictate and Converse for sure Then workspace and voices" - with the
choice of when to show it left to me.

With the panel, never at rest. Those three only mean anything once there is something to
send, and an always-on strip would cost 22 px of the idle row, which is the part of this
surface everybody has said they like small. FluidVoice does not pay it either: its
Dictate / AI Prompt / Actions bar belongs to the app being dictated into, not to the
overlay.

Mode is a control and the other two are values, which is the whole design. Mode changes
what Send does and is switched mid-task, so it is a chip that acts. Workspace and voice
are worth *seeing* - which project Ask runs in, without opening anything - and clicking
either opens the list the right-click menu already builds, tick and all, rather than
growing a second implementation of it.

The strip is furniture, so PANEL_MAX_H grew by SETTINGS_H rather than the draft paying
for it. Taking it out of the content's share is what the editor tests caught: the live
partial's cap is a flat 70 px, and on a panel pegged at the old ceiling it ran straight
through the note and the chip row because everything above it had grown by 22 and it had
not been told.

One test relaxed from assertGreater to assertGreaterEqual, honestly: _settled_h steps by
a whole body line, so a note that grew by 14 px can land in the same bucket. That is what
the snap is for, and the property that test is really about - the note drawn whole, and
clear of the chips - is asserted beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant