Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 16 additions & 13 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,13 +450,17 @@ through a seam it is handed, so it is exercised by feeding it events, not hardwa
_Avoid_: controller, manager, orchestrator

**Settings Snapshot**:
An immutable `Settings` value the daemon reads at any moment. The menu bar — the sole
writer, on the main thread — swaps in a complete fresh snapshot per change; readers
acquire-load once and see a coherent whole. Old snapshots leak by design, so a holder
(e.g. a connected Transcription Session) is never invalidated. `config.zon` stays the
canonical hand-editable form of the same settings.
The immutable settings currently accepted by the daemon, shared coherently by its readers.
A snapshot remains valid for every reader holding it, even after a newer one is published.
_Avoid_: mutable config, live config object

**Settings Snapshot Publication**:
The shared acceptance of a menu edit or file reload as the live Settings Snapshot,
including the resulting backend selection, Transcription Session changes, Vocabulary
rebias and HUD enablement. A live edit may remain unsaved; a valid file reload replaces
it, while a failed reload preserves it and an unaccepted edit has no resulting effects.
_Avoid_: settings manager, config refresh (that names only the reload path)

**Grant Observer**:
The daemon's one owner of the three macOS TCC grant facts — Microphone, Input Monitoring,
PostEvent — and of the serialized cold-start request ladder that asks for them (#130,
Expand Down Expand Up @@ -623,19 +627,18 @@ It exists because `config.Settings`
holds slices, which no comparable value can carry: reducing them to scalars is what keeps
the Presentation comparable, and therefore what keeps the Chrome's early-out honest. The
radio-group table lives beside it in `status_item.zig` — which option *reads* as selected is
presentation — while `menu.zig` keeps the write path that turns a click into a field
(ADR-0011).
presentation — while the menu routes its typed choice through Settings Snapshot
Publication.

The table is the **single source for both directions**. One curated option is written down
once — its row label and the typed value it sets — and everything else is derived: its
`config.zon` text (`.tag` for the closed enums, a quoted literal for the string-shaped
fields), `menu.applyOption`'s write, and `currentOption`'s read-back. That is why the table
carries two names, `specs` and the `groups` projected from it: a homogeneous `[_]GroupDef`
persistence through Settings Snapshot Publication, `menu.editOption`'s typed edit, and
`currentOption`'s read-back. That is why the table carries two names, `specs` and the `groups` projected from it: a homogeneous `[_]GroupDef`
**cannot** hold the typed values, because `[]const TalkKey` and `[]const []const u8` share no
element type, so a tuple holds them and the array serves the loops that index by a runtime
group index. Collapsing the two back into one array is the regression to avoid — it is what
evicted the typed values into seven parallel arrays coupled by index alone, leaving three
representations of one option that could silently disagree. Now a label paired with the wrong
value, a value that contradicts the bytes persisted, a group inserted mid-table, and a `field`
naming nothing are all compile errors rather than things a test has to catch.
representations of one option that could silently disagree. The field type is checked at
compilation; publication derives the bytes persisted from the same typed value, and a test follows every radio choice through publication, disk and
read-back.
_Avoid_: settings snapshot (that is the live `Settings` the daemon reads), config view
51 changes: 51 additions & 0 deletions docs/settings-publication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Settings Snapshot Publication

Menu edits and file reloads share `src/settings_publication.zig`. This completes the
settings write work deferred by ADR-0011. The Status Item still owns presentation;
its AppKit adapter collects intent and displays the publication outcome.

## Interface and ownership

An edit supplies a field and its typed value. Publication owns clamping, retained copies,
serialization, change detection, snapshot publication, daemon effects, and persistence.
A reload reads the canonical file itself. Callers do not supply serialized text, change
flags, or independently assembled snapshots.

All operations run on the main thread. Accepted snapshots retain their storage for the
process lifetime, as before; a Session holding an older snapshot remains valid. Store
publishes the log-redaction policy before the new pointer. Other daemon effects follow
publication and therefore see the accepted snapshot.

## Outcomes

| Operation | Live snapshot and effects | Disk |
| --- | --- | --- |
| Changed edit | Prepare, publish, then dispatch changed effects | Attempt persistence last |
| Same-value Save | Keep pointer; no effects | Retry persistence |
| Preparation failure | No publication or effects | No write |
| Persistence failure | Accepted edit remains live | Existing file preserved |
| Valid changed reload | Replace live settings and dispatch changed effects | No write |
| Unchanged reload | Keep pointer; no effects | No write |
| Missing, unreadable or malformed reload | Keep live settings | No write |

A valid file wins over unsaved live edits on the next reload. A valid empty settings
value (`.{}`) intentionally resets settings to defaults. Startup separately retains its
existing defaults-on-failure behavior.

Menu edits derive from the current live snapshot. Persistence reads the file afresh and
patches only the edited field, so unrelated hand edits stay on disk until the next reload.
An existing malformed or unpatchable file is never replaced by a complete serialization.
A multiline Vocabulary value can therefore require a manual file edit. A complete file is
created when absent. Successful writes use a temporary sibling and rename.

Explicit edit failures show “Couldn't apply settings” or “Applied, but couldn't save.”
Failed reloads log their reason without interrupting menu-open with an alert.

## Verification

Tests cross the publication interface with real temporary files and a recording daemon
effect adapter. They cover publication/effect/write ordering, allocation failures, failed
persistence and retries, reload authority, hand-edit preservation, retained borrowed data,
and each radio choice through publication, disk and read-back. The pure diff and parser
tests remain useful; the old isolated radio serialization tests are replaced by that
end-to-end route. AppKit alert presentation still requires a manual display check.
46 changes: 25 additions & 21 deletions docs/vocab-biasing-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
(see [Branching & handoff](#branching--handoff)). The map is planning-only —
no code shipped by it.

> **Settings publication update (2026-09-13):** [Settings Snapshot Publication](settings-publication.md)
> supersedes this document’s original commit helper, full-file fallback, and reload
> failure behavior. Its rules apply to Vocabulary and every other setting.

> **Premise stale (2026-07-29):** this spec locked while `gpt-realtime-whisper`
> was the OpenAI default and reasons throughout from "the default transcription
> model cannot accept a prompt." The 0.4.0 default flip to `gpt-live-transcribe`
Expand Down Expand Up @@ -121,8 +125,8 @@ In `loadSettingsOnly`, after parse:
- Blank / whitespace-only items (`""`, `" "`) → dropped.
- **No dedup** at this layer.

A *type* mismatch (e.g. `.vocabulary = 5`) still falls the whole file back to
defaults via the existing `zonValid` / `std.zon` all-or-nothing path — unchanged.
A *type* mismatch (e.g. `.vocabulary = 5`) uses defaults at startup. Live reload
rejects the file and keeps the last accepted Settings Snapshot.

### In-place array patch — single-line, quote-aware (build it)

Expand All @@ -134,8 +138,9 @@ array literal's inner comma cuts the span and corrupts the file. Fix:
tracking `"…"` so string-internal commas don't terminate the span.
- The menu **always serializes `vocabulary` on one line**
(`.vocabulary = .{ "a", "b" },`). A multi-line hand-formatted array returns
`null` → **full re-serialize fallback** (same as today's "value on the next line"
case at `config.zig:356`). Comments are preserved on the common single-line path.
`null` → **preserve the existing file and report an unsaved live edit**.
Settings Snapshot Publication supersedes the original full-file fallback; comments
are preserved on the common single-line path.
- `patchZonField`'s absent-field insert path already writes
` .vocabulary = <value>,` on one line, so it works unchanged.

Expand Down Expand Up @@ -258,23 +263,22 @@ Placed in the bottom settings cluster, **just above "Open config file"**

### Live-edit flow through the single-writer swap

On **Save**, entirely on the main thread (the menu is the sole Settings-Snapshot
writer):

1. Read the `NSTextView` string.
2. Split on newlines → trim each line → drop blank lines.
3. Apply the §1 clamp: drop items > 100 chars; keep at most 128 items; no dedup.
4. Build the `vocabulary: []const []const u8` value.
5. `commitSettings(next, "vocabulary", <serialized array>, session_shaped = false)`
(`menu.zig:681`).

`commitSettings` does the whole publish sequence: copies the live snapshot,
`store.swap(heap)` publishes the new immutable snapshot (`config.zig:247`),
`config.writeField` persists to `~/.config/type-wave/config.zon` (the **single-line
quote-aware array patch** from §1; multi-line arrays fall back to full
re-serialize). Because vocabulary is **`session_shaped = false`** (§4), there is
**no `markSessionDirty`** — Whisper picks the new list up at the **next Talk-Key
press**, pinned with the Lease. **Cancel** performs no swap and no write.
On **Save**, entirely on the main thread:

1. Read the `NSTextView` string; split on newlines, trim, and drop blank lines.
2. Submit the typed Vocabulary edit to Settings Snapshot Publication.
3. Publication clamps and retains the terms, derives the change, publishes the complete
snapshot, dispatches effects, then attempts a single-field disk patch.

An actual Vocabulary change requests a warm OpenAI rebias, never a session cycle;
Whisper reads the list at the next Talk Key press, pinned with the Lease. Saving the same
list retries persistence without publishing or dispatching effects. Preparation failure
leaves everything unchanged. A failed patch keeps the live edit but preserves the existing
file and reports that it was not saved; a complete file is created only when absent.
**Cancel** performs no publication and no write.

See [Settings Snapshot Publication](settings-publication.md) for reload authority and
failure behavior shared by all settings.

### Empty / first-run state

Expand Down
Loading
Loading