A writing style you can pick or write, on each stage separately - #26
Merged
Conversation
Cancelling an active dictation worked on every client already. On three of them nothing said so, which for a feature reachable only by a key or a gesture is the same as not having it: the desktops bound Escape and never printed it, and both phones cancelled capture by dragging off the talk button before letting go — undiscoverable, and with nowhere on a keyboard to be documented. What was left in practice was to let a recording you did not mean finish, pay for it, and delete what it typed. Phones get a control, shown only while there is something to abandon and named for which half of the dictation that is: Discard recording while the microphone is open, Cancel transcription once the request has gone. Two names rather than one because the two lose different things. On Android it is a cross beside the talk button — deliberately not on the utility row, which is always there and already has Backspace on it — and on iOS the keyboard's existing Cancel button simply stops hiding itself during recording. Both hosts route through one new DictationController.cancelActive(), because the state changes under the finger: a request starts the moment a recording ends, and a control that went inert at exactly that boundary would fail when it is most wanted. The desktops name the key instead of growing a button. Their overlay ignores the mouse on purpose — it hangs over whatever is being typed into, and a pill that swallowed clicks would take them from the application underneath — so the fix is that the pill says "Esc to cancel", in the recording phase and in the transcribing phase, where the wait is longer and the way out matters more. RecordingHint composes that row in the core of both desktops, with the same four cases asserted in Swift and C#: the key's *name* stays with the client, because macOS says Return where Windows says Enter, but the order and the join do not, or the row reads differently on the two platforms. swift test, dotnet test and ./gradlew test pass. The iOS UI tests pass with one pre-existing failure (testAPIKeySurvivesLeavingSettings), which reproduces on main; a new UI test covers discarding a recording. Android compiles and unit tests; untested on a device.
The space between Chinese and Latin was whatever the model felt like on the request. The same sentence came back spaced once and tight the next time, and sometimes with a stray space after a full-width full stop, which no convention allows anywhere. Every individual output was defensible; the set of them was not, and inconsistency is what was reported. The fix is split along the line between what can be decided locally and what cannot. Spacing is arithmetic, so it happens on the device. Typography runs in TranscriptionService after both audio guards and before the result leaves — the same choke point and the same argument as HallucinationGuard: dictation, file transcription, retry, redo and both CLIs all come through it, so history and the cursor cannot disagree, and a caller cannot forget it. One space at the boundary is the default, with "no space" and "leave it as the model wrote it" beside it; space next to a full-width mark is removed under both rules, because that one is not a preference. Three constraints keep it safe to run on every transcript. It may only add or remove horizontal space — all three suites assert the input and output are identical once whitespace is dropped. Newlines are not horizontal space, so it cannot join two lines. And Hangul is outside the CJK class while kana is inside it: Korean separates its own words, so "no space" would break the language, whereas excluding kana would space Web開発 and not Webかいはつ, which is the inconsistency the whole thing exists to remove. The 22-row table is repeated verbatim in Swift, C# and Kotlin, like the mode grammar. Script and punctuation are not arithmetic, so they are asked of the model. prompt/typography.md carries a chosen Chinese script — Simplified against Traditional was drifting inside single dictations — plus the request to separate clauses with full-width punctuation rather than spaces. prompt/sample.md carries the user's own formatting example, framed three times as an example and not as speech, the way screen context is. Replacing a space with a comma stays a request rather than a transform, because a comma the speaker did not say is a content change and this project does not make those locally. Both blocks are absent from a default request, and that is load-bearing rather than tidy: every measured number in docs/PROMPT.md describes the default request, and a clause added to it unconditionally would invalidate the whole table at once. The equality is a test, not a claim. Measured as a control rather than a before/after, since nothing sent a new block: 48 runs, 38 matched, 7 improved, 2 regressed, against the 2026-08-19 baseline's 37/11/2 — inside the per-pass range the runner prints for itself. The two regressions are the standing benefit-novel-repo failures. The measurement harness turns typography off explicitly, which is the second deliberate divergence in ARCHITECTURE.md: a suite scoring against ground truth transcribed from the backend's output should not be scoring this app's own transform. swift test (577), dotnet test (588) and ./gradlew test pass; iOS builds and its simulator tests pass. Android and Windows compile and unit-test; untested on a device.
CI's `dotnet format whitespace --verify-no-changes` rejected an attribute left on the same line as a property whose initialiser wraps. Caught by the Windows source-validation job, which is the one part of the matrix a Mac cannot run.
A target language in Settings, off by default, on all four clients and from `dnt transcribe --mode translate:English`. This is the one setting in the product that makes the main control deliver something other than what was said, so it is worth being exact about what it does not change. The verbatim transcript is still produced first, stored first, and recoverable — ⌘⌥Z, Ctrl+Alt+Z, the History row on both phones. That is the actual promise; "the main key stays verbatim" was how it had been kept so far, not the thing being kept. A translation you cannot expand back into the words that produced it is the failure this project was built against. One you can is a convenience sitting beside them. Three decisions worth the review time: It replaces the rewrite stage rather than joining it. One request doing two jobs is the combination already measured at twice the substitution rate, and "formal French" is a feature request rather than a fix for what was asked for. Every client says so where the rewrite control is, through a new RewriteAvailability.translating case, rather than leaving a picker that quietly does nothing — the same reason that type exists at all. It folds into the request that transcribes, wherever the backend can answer the wider schema. Same argument as the rewrite: that request is the only one holding the audio, and a translator working from text alone applies world knowledge and "corrects" a version number it believes is stale. Recognisers, split recordings and the live pipeline keep the second pass. StyledRequest replaces the bare style-clause parameter for this: a rewrite and a translation are different jobs asked for through the same field, and two optional parameters would have made "both at once" a state somebody has to remember not to construct. The language is free text with a shape check, exactly like the Model field and for the same reason. "Traditional Chinese", "Brazilian Portuguese" and "plain English" are all things a model can do and none is a row in an enum anybody would have thought to add. TranslationTarget checks shape, never existence; the suggestions are a shortcut and the suites assert they are not a whitelist. prompt/translate.md is its own part rather than a rewrite style, because the rewrite block's first rule is *keep the speaker's language* — the same reasoning that keeps the summary separate, where the rule at stake is "never remove a fact". Measured as a control, since the transcription request is untouched: 48 runs, 36 matched, 9 improved, 3 regressed. The regressed count moved from 2 and the honest reading is that it did not move — the runner printed `0–2 per pass` beside it and named real-version-number, the third regression, as one of two cases giving different answers across passes in that same run. swift test (582), dotnet test (607) and ./gradlew test pass; the mode grammar table gains eight translate rows in all three suites. iOS builds and its simulator tests pass, with the same pre-existing testAPIKeySurvivesLeavingSettings failure that reproduces on main. Android and Windows compile and unit-test; untested on a device.
`prompt show --section system` printed the bare contract while the app appends two more paragraphs to it, which makes the command useless for the one job it has — telling somebody what is being sent. It now reads the same two settings the app does, and gains a `translate` section beside `rewrite` and `summary`. Verified end to end against a real code-switched recording: with the spacing setting on `tight`, `一个retrieval pipeline,` and `这个index已经` come back tightened while the space inside "retrieval pipeline" is left alone.
Found reading the branch back. The Windows tray built its "what is happening" label as TranscriptMode.Rewrite(SecondaryStyle).ProgressLabel in two places, which is a second answer to a question the controller had already answered — so a translation showed "Loosening…" in the overlay and in the menu while a translation was in flight. AppSettings.SecondStageFor is now the one definition and both callers use it. The Kotlin PromptPart carried two consecutive KDoc blocks on isClause, because the new note was inserted ahead of the old one instead of into it.
bojieli
force-pushed
the
feat/translation
branch
from
August 30, 2026 11:00
0270fb3 to
406d9c3
Compare
Two settings, because they are two jobs with opposite permissions. A dictation style decides how the words are written down and may not change one of them; a rewrite style decides how they are said again and is the stage allowed to reword. Selecting Chat for one and Formal for the other is a legitimate combination and neither implies the other, which is why one control with a mode switch would have been the wrong shape. Each is filled either way. A dropdown covers the people one of a few answers fits, in one tap; a text box covers everybody else. Neither alone is enough — three shipped rewrite styles are three guesses at what somebody wants their email to sound like, and a blank box in a settings screen is a question most people will not answer. Dictation offers As spoken, Chat, Notes, Prose and Custom; rewriting offers Formal, Concise, Casual and Custom. The custom text is substituted into the same host block a preset lands in. prompt/dictation-style.md opens with the rule its clause could otherwise be read as relaxing — this governs how it is written, never what it says — and then frames what follows as a style rather than as speech: not to be transcribed, answered, continued or borrowed from. RewriteStyle.custom goes into prompt/rewrite.md the same way, so "never remove a fact" covers the user's own sentence exactly as it covers ours. A custom style that bypassed its block would be user-authored text sitting unframed in a system instruction, which is the one thing this directory's rules exist to prevent. Two absences are load-bearing. `spoken` sends nothing, which is what keeps the measured changelog describing the request a default install makes — asserted rather than claimed, on all three platforms that can read prompt/ from a unit test. And an empty Custom sends nothing either: a rewrite instruction whose style clause is blank asks the model to write in no particular way, and it would do something. This generalises the formatting example from earlier in the series — same idea, now with presets beside it and a matching control on the rewrite stage — so prompt/sample.md becomes prompt/dictation-style.md and its setting becomes the Custom entry. `rewrite:custom` joins the mode grammar in all three parity tables, and both settings and both custom texts travel in the transfer profile. swift test (584), dotnet test (617) and ./gradlew test pass. iOS builds; macOS verified end to end through `dnt prompt show`, which now prints the instruction a dictation would actually send rather than the bare contract.
Three things the emulator and a second read caught. An empty second-stage instruction is now nil in the core rather than at four call sites. A custom style with nothing in it, and a translation with no language, both assemble to nothing — and a second stage sent with an empty system instruction is a model asked to do something unspecified to a transcript, which it would. Each settings screen now says what choosing Custom and typing nothing means: no rewrite, and the transcript as it is. The Android settings instrumentation test asserted the rewrite picker offers exactly three styles. It offers four now, and that assertion is the reason the list and the enum cannot drift, so it gains the fourth rather than losing the check. A new case covers the dictation style picker, including the part worth having: choosing a preset must not throw away the custom text somebody wrote. The near-miss suite was run as a control for the third time in this series and came back at 38/8/2 — the same figures as the typography control, which is what an unchanged request should look like, and which puts the translation entry's 36/9/3 where it belongs: inside the noise the runner prints for itself.
…tlin The picker is the control the formatting story now hangs off, and the failure it guards against is a setting that saves on change but never reloads: fine on the screen, gone when you come back. It follows the fidelity test's shape because it is the same assertion about a different row. Also drops a redundant `val mode = stage` alias and wraps the line it made too long.
bojieli
force-pushed
the
feat/writing-style
branch
from
August 30, 2026 11:16
8d193ea to
6642ef3
Compare
Reported from a Pixel after the first fix shipped: the bottom row is still too low. Measured on an API-35 screen at 2.625x, and the report was right. The first fix padded by the navigation inset plus a small gap. That inset is not a constant: gesture navigation reports 24dp — the home pill's strip — where three-button navigation reports 48dp. So the mode nobody complained about ended up with 58dp of clearance and the mode they were using got 34dp. The ergonomic requirement does not vary like that. A thumb needs the same room away from the bottom edge either way. So the rule is a floor rather than a sum: never closer to the bottom of the screen than MIN_BOTTOM_CLEARANCE_DP, and never closer to the navigation bar than PAD_BOTTOM_DP. Below API 35 neither applies, because there the window sits above the bar and a floor would be that many dp of dead keyboard — asserted, because that is the opposite bug and it is the one a floor invites. 60dp is Gboard's, measured on the same screen with the same method: its bottom key row ends 62dp above the bottom edge, against this keyboard's 34dp before and 61dp after. Gboard is the right target because it is what the complaint is implicitly comparing against — whatever number this project reasons its way to, the hand holding the phone is calibrated on Gboard. The room comes out of slack that was already there. The status line and level meter reserve about 60dp above the talk button and most of it is empty while idle, so the bar was bottom-heavy before it was anything else. Verified by screenshot on the emulator at each step rather than by reading the code: 34dp before, 49dp with the floor at 48, 61dp with it at 60.
The third report of the same thing, and the first two fixes were both about clearance underneath. Clearance was not the whole problem. An Android IME is anchored to the bottom of the display, so where a control sits *within* the bar is where it sits under the thumb — and with a status label and a level meter above it, the one control this keyboard exists for was the second-lowest thing on the screen. Putting it first lifts it about 60dp without making the keyboard any taller, because the two rows it swaps with are the two nobody presses: one is a label and the other is a readout. The meter follows the button rather than leading it for the same reason it was ever adjacent — it reports on what the button is doing — and the status line goes under both, where a sentence belongs. The top padding goes from 6dp to 12dp, because 6dp was right above a line of text and leaves a 58dp capsule flush against the edge. This is a deliberate divergence from the iOS bar, which the two otherwise match on purpose, and PARITY records why: iOS pins its keyboard to a fixed height that the system already keeps clear of the home indicator, so the same move would buy it nothing there. Measured on the reporter's Pixel 9 Pro Fold at 2.4375x: the talk button now sits 176dp above the bottom edge against 116dp before, and the utility row keeps its 59dp. Confirmed working by the reporter.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes, and why
Requested: a writing style you can either choose from presets or type yourself, configured
separately for dictation and for rewriting.
Two settings, because they are two jobs with opposite permissions. A dictation style decides
how the words are written down and may not change one of them; a rewrite style decides how they
are said again and is the stage allowed to reword. Chat for one and Formal for the other is a
legitimate combination and neither implies the other — which is why one control with a mode switch
would have been the wrong shape.
Each is filled either way. A dropdown covers the people one of a few answers fits, in one tap;
a text box covers everybody else. Neither alone is enough: three shipped rewrite styles are three
guesses at what somebody wants their email to sound like, and a blank box in a settings screen is a
question most people will not answer. Dictation offers As spoken, Chat, Notes, Prose, Custom;
rewriting offers Formal, Concise, Casual, Custom.
The custom text goes through the same host block a preset lands in.
prompt/dictation-style.mdopens with the rule its clause could otherwise be read as relaxing —this governs how it is written, never what it says — and then frames what follows as a style rather
than as speech: not to be transcribed, answered, continued or borrowed from.
RewriteStyle.customsubstitutes into
prompt/rewrite.mdthe same way, so never remove a fact covers the user's ownsentence exactly as it covers ours. A custom style that bypassed its block would be user-authored
text sitting unframed in a system instruction, which is the one thing this directory's rules exist
to prevent.
Two absences are load-bearing.
spokensends nothing, which keeps the measured changelogdescribing the request a default install makes. And an empty Custom sends nothing either — a
rewrite instruction with a blank style clause asks the model to write in no particular way, and it
would do something — so
secondStageInstructionreturns nil for it in all three cores, and everysettings screen says what that means.
This generalises the formatting example from #24: same idea, now with presets beside it and a
matching control on the rewrite stage.
prompt/sample.mdbecomesprompt/dictation-style.mdandits setting becomes the Custom entry.
Numbers, if this touches transcription quality
Nothing reaches a default request, and that equality is asserted rather than claimed
(
testTheDefaultRequestIsUnchangedByTheseFeaturesExisting, and its C# port). Run as a control:swift run dnt-eval suite eval/nearmiss --repeat-count 3, gemini-3.5-flash, 48 runs. Back to thefirst control's figures, which is what an unchanged request should look like — and which puts
#25's 36/9/3 where it belongs: inside the noise the runner prints for itself.
Platforms
Both settings and both custom texts travel in the transfer profile (optional in the format, so an
older profile still imports and keeps what the device has).
rewrite:customjoins the mode grammarin all three parity tables.
Verified on devices this time, which is new for this repo's Android side:
./gradlew connectedDebugAndroidTest),including a new one for the dictation style picker that asserts choosing a preset does not throw
away the custom text. This run is what caught a stale assertion in
SettingsActivityTest— the rewrite picker offers four styles now.xcodebuild teston a simulator, plus a new UI test that picks Chat, leaves Settings andcomes back, guarding the failure a save-on-change setting has: fine on the screen, gone when you
return.
/Applicationsand launch-checked, plusdnt prompt showprinting the assembled instruction with a preset and with a custom style.
swift test(584),dotnet test(618) and./gradlew testpass.Two pre-existing failures, both reproduced on
main.DNT_INTEGRATION=1 swift testhas one that is worth knowing about:testScreenContextSubstitutionStaysWithinTheMeasuredRatefails roughly half the time. Sampled tenruns — main passed 3 of 5, this branch 1 of 5 — and the system instruction the harness builds is
byte-identical between the two trees (diffed, 1143 bytes each), so this is the clip saturating
against the current default model rather than anything in the series.
docs/PROMPT.mdalreadyrecords
real-talk-gemini15.wavlosing its power to discriminate on gemini-3.5-flash; it appears tohave lost it on 3.6 as well, and the 0.85 threshold no longer holds. Left alone: moving a measured
threshold on ten samples is not something to do quietly. (Two more integration tests fail on an
OpenRouter 402 — the account is out of credits.)
One pre-existing iOS UI test still fails:
testAPIKeySurvivesLeavingSettings, which reproducesunchanged on
main. I diagnosed it and did not fix it — the accessibility dump shows the settingsform pinned at
Vertical scroll bar … value: 0%after all eight ofreveal's scroll attempts onthe second visit to the screen, while the typed key is confirmed present in the field before
navigating away. So the assertion fails on a form it cannot scroll rather than on a key that was
not saved. Dismissing the keyboard first (Return, tapping the nav bar) and dragging instead of
swiping both failed to move it. Left alone rather than guessed at.
Follow-up from real use
Installed on a Pixel and the bottom row was reported as still too low. Measuring said the report
was right and the first fix was aimed one notch short: it padded by the navigation inset plus a
gap, and that inset is not a constant — gesture navigation reports 24dp where three-button reports
48dp, so the mode nobody complained about got 58dp of clearance and the mode in use got 34dp.
The rule is now a floor rather than a sum, with the floor set from Gboard: measured on the same
screen, Gboard's bottom key row ends 62dp above the bottom edge. This keyboard was at 34dp and is
now at 61dp. Gboard is the right target because it is what the complaint is implicitly comparing
against.
Below API 35 neither the floor nor the inset applies, because there the window sits above the bar
and a floor would be that many dp of dead keyboard — the opposite bug, and the one a floor invites,
so it has a test of its own.
Verified by screenshotting the emulator at each step rather than by reading the code: 34dp before,
49dp with the floor at 48, 61dp with it at 60.
That still was not it. Reported a third time, and clearance was never the whole problem. An
Android IME is anchored to the bottom of the display, so where a control sits within the bar is
where it sits under the thumb — and with a status label and a level meter above it, the one control
this keyboard exists for was the second-lowest thing on the screen.
The talk button now comes first in the bar, which lifts it about 60dp without making the keyboard
any taller, because the two rows it swaps with are the two nobody presses: one is a label and the
other is a readout. The meter follows the button rather than leading it — it reports on what the
button is doing — and the status line goes under both, where a sentence belongs.
This is a deliberate divergence from the iOS bar, which the two otherwise match on purpose, and
PARITY records why: iOS pins its keyboard to a fixed height the system already keeps clear of the
home indicator, so the same move buys it nothing there.
Measured on the reporter's Pixel 9 Pro Fold at 2.4375x: the talk button sits 176dp above the
bottom edge against 116dp before, and the utility row keeps its 59dp. Confirmed working by the
reporter on the device.
Checks
swift test,dotnet testand./gradlew testpass