Skip to content

One chip, three modes: Dictate, Rewrite, Translate on the phones - #27

Merged
bojieli merged 6 commits into
mainfrom
feat/mode-picker
Aug 31, 2026
Merged

One chip, three modes: Dictate, Rewrite, Translate on the phones#27
bojieli merged 6 commits into
mainfrom
feat/mode-picker

Conversation

@bojieli

@bojieli bojieli commented Aug 30, 2026

Copy link
Copy Markdown
Owner

What this changes

The phones' mode control was a two-state Dictate/Rewrite toggle. A target
language set in Settings silently overrode it, so the chip could read Rewrite
over a dictation that came back translated, and switching between them meant
leaving the text field, opening the app, and coming back.

It is now one three-way picker: a menu on each keyboard, three segments on each
app's own screen. TranscriptMode already had exactly these three cases;
LiveMode names them for what the user is choosing, with a single resolver from
mode to stage, so "both at once" is unconstructible rather than merely avoided.

The target language stays in Settings, and the picker does not offer a language
list. A keyboard cannot type into its own popup, so a list there would be a fixed
handful quietly disagreeing with the free-text target the app already stores —
and the popup's job is to answer one question.

Modes that cannot run

They are offered and disabled with the sentence saying why, which is what this
project already does for an unavailable rewrite. Rewriting and translating need
the same thing — a backend that takes text and gives text back — so they share
one rule and differ only in the noun: SecondStageJob. Telling someone their
backend "cannot rewrite text" when they asked for a translation sends them to fix
the wrong setting. The rewrite sentences are byte-identical to what they were,
so the existing parity assertions still mean what they meant.

Translate with no language configured is the state the old toggle could not
represent at all. It gets its own case and its own sentence.

On iOS the extension cannot read the app's Keychain, so it could never say why a
rewrite would not run — it just offered it. The app now publishes which case the
shared rule landed on and the keyboard rebuilds the sentence from it, so the
wording still lives in exactly one place.

The desktops are unchanged

macOS and Windows pick the operation by which key is held, so a persistent chip
would be a second answer to a question the keyboard has already answered. There a
target language still replaces what the second key produces, and the settings
window still says so — now through RewriteAvailability.forSecondKey rather than
through resolve, which the phones use.

Migration

Both phones read the old settings: a target language means Translate, a rewrite
style means Rewrite, anything else is Dictate. That is what an older build
actually did with those two settings. The settings-transfer document keeps its
shape, so a file written here still opens in an older client.

Verified

  • Swift 593 tests, Kotlin unit tests, C# 619 tests — all pass.
  • Android instrumentation on the emulator: the three segments exist, Translate is
    refused with no language set, and choosing a mode that cannot run does not
    become the stored one.
  • iOS shared tests (32) including the new bridge cases, and the mode UI test on
    the simulator.
  • On the Pixel: the menu opens over the keyboard with Translate greyed out;
    choosing Rewrite stores liveMode=rewrite and repaints the chip; the app screen
    shows three segments. Installed and left on Dictate.
  • On the iPhone: built and installed for the device.

🤖 Generated with Claude Code

https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG

bojieli and others added 6 commits August 30, 2026 23:04
The second stage has three possibilities and `TranscriptMode` has said so for a
while: verbatim, rewrite, translate. The phones offered two of them, as a
Dictate/Rewrite toggle, while a target language in Settings silently overrode
it — so the chip could read Rewrite over a dictation that came back translated,
and moving between them meant leaving the text field, opening the app, and
coming back.

`LiveMode` is what the user is choosing, with one resolver from the mode to the
stage, so the three are exclusive by construction rather than by a settings flag
beating a toggle. Only the phones have one: a desktop chooses by which key it is
holding, so a persistent chip would answer a question the keyboard has already
answered.

`RewriteAvailability` grows the job it is being asked about. Rewriting and
translating need exactly the same thing — a backend that takes text and gives
text back — so they share the rule and differ only in the noun, because telling
someone their backend "cannot rewrite text" when they asked for a translation
sends them to fix the wrong setting. The rewrite sentences are byte-identical to
what they were.

The desktops keep the answer they had. There a target language still replaces
what the second key produces, and the settings window still says so, now through
`forSecondKey` rather than through `resolve` — the phones never ask it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
Hand-ported, with the rewrite wording unchanged so the existing parity
assertions still mean what they meant. Windows has no mode chip — a desktop
picks the operation by which key is held — so it gets `ForSecondKey` and keeps
reporting a target language beside the rewrite picker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
The Kotlin half of the same rule, with the parity table duplicated rather than
shared: a fixture read from disk would be read by whichever platform remembered
to read it. This suite also gains the two sentences the Swift and C# suites
already asserted literally, which Kotlin had been missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
Tapping the chip on the keyboard opens a flat menu of Dictate, Rewrite and
Translate; the app's own screen grows the third segment. Deliberately flat: the
target language stays in Settings, because a keyboard cannot type into its own
popup and any list offered there would be a fixed handful quietly disagreeing
with the free-text target the app already stores.

A mode that cannot run is offered and disabled with the sentence saying why —
Rewrite without a backend that can rewrite, Translate with no language set —
rather than being absent, which is how the rewrite feature came to look as
though it did not exist. A disabled menu item swallows its own click, so the
keyboard answers in the status line instead.

The stored mode migrates from what an older build actually did with the two
settings it had: a target language means Translate, a rewrite style means
Rewrite, anything else is Dictate.

Verified on the Pixel: the menu opens over the keyboard with Translate greyed
out, choosing Rewrite stores `liveMode=rewrite` and repaints the chip, and the
app screen shows the three segments with Translate disabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
The chip opens a `UIMenu` of the three modes instead of toggling between two,
and the app's own badge grows the third segment. The bridge carries a mode
rather than a boolean, migrating an install that had the old switch.

The extension cannot read the app's Keychain, so it could never say why a
rewrite would not run — it just offered it. The app now publishes which case the
shared rule landed on and the keyboard rebuilds the sentence from it, so the
wording still lives in one place and a disabled item explains itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
@bojieli
bojieli merged commit 41e12c7 into main Aug 31, 2026
13 checks passed
@bojieli
bojieli deleted the feat/mode-picker branch August 31, 2026 01:19
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